loyalty_ref 0.1.0
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 +7 -0
- data/CHANGELOG.md +27 -0
- data/LICENSE.txt +21 -0
- data/README.md +194 -0
- data/app/assets/javascripts/loyalty_ref.js +138 -0
- data/app/controllers/loyalty_ref/dashboard_controller.rb +91 -0
- data/app/controllers/loyalty_ref/tracking_controller.rb +30 -0
- data/app/helpers/loyalty_ref/application_helper.rb +56 -0
- data/app/helpers/loyalty_ref/dashboard_helper.rb +41 -0
- data/app/views/layouts/loyalty_ref/dashboard.html.erb +233 -0
- data/app/views/loyalty_ref/dashboard/analytics.html.erb +80 -0
- data/app/views/loyalty_ref/dashboard/index.html.erb +78 -0
- data/app/views/loyalty_ref/dashboard/referrals.html.erb +41 -0
- data/app/views/loyalty_ref/dashboard/transactions.html.erb +31 -0
- data/app/views/loyalty_ref/dashboard/user.html.erb +50 -0
- data/app/views/loyalty_ref/dashboard/users.html.erb +40 -0
- data/config/routes.rb +14 -0
- data/db/migrate/001_create_loyalty_ref_transactions.rb +21 -0
- data/db/migrate/002_create_loyalty_ref_referral_logs.rb +28 -0
- data/lib/generators/loyalty_ref/install/install_generator.rb +42 -0
- data/lib/generators/loyalty_ref/install/templates/initializer.rb +63 -0
- data/lib/generators/loyalty_ref/install/templates/user_model.rb +45 -0
- data/lib/loyalty_ref/configuration.rb +32 -0
- data/lib/loyalty_ref/engine.rb +34 -0
- data/lib/loyalty_ref/models/referral_log.rb +109 -0
- data/lib/loyalty_ref/models/transaction.rb +37 -0
- data/lib/loyalty_ref/version.rb +5 -0
- data/lib/loyalty_ref.rb +191 -0
- metadata +240 -0
@@ -0,0 +1,63 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
LoyaltyRef.configure do |config|
|
4
|
+
# Define which model represents your app's user/customer
|
5
|
+
config.reference_class_name = 'User' # or 'Customer', 'Account', etc.
|
6
|
+
|
7
|
+
# Custom earning rule - earn points based on events
|
8
|
+
config.earning_rule = ->(user, event) do
|
9
|
+
# Example: earn 10 points per ₹100 spent
|
10
|
+
# event.amount / 10
|
11
|
+
|
12
|
+
# Default: earn 1 point per event
|
13
|
+
1
|
14
|
+
end
|
15
|
+
|
16
|
+
# Custom redeem rule - how many points to deduct
|
17
|
+
config.redeem_rule = ->(user, offer) do
|
18
|
+
# Example: offer.cost_in_points
|
19
|
+
offer&.cost_in_points || 100
|
20
|
+
end
|
21
|
+
|
22
|
+
# Define tier thresholds
|
23
|
+
config.tier_thresholds = {
|
24
|
+
"Silver" => 500,
|
25
|
+
"Gold" => 1000,
|
26
|
+
"Platinum" => 2500
|
27
|
+
}
|
28
|
+
|
29
|
+
# Reward modifier based on tier
|
30
|
+
config.reward_modifier = ->(user) do
|
31
|
+
case user.tier
|
32
|
+
when "Silver" then 1.0
|
33
|
+
when "Gold" then 1.2
|
34
|
+
when "Platinum" then 1.5
|
35
|
+
else 1.0
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# Referral reward logic
|
40
|
+
config.referral_reward = ->(referrer, referee) do
|
41
|
+
LoyaltyRef.earn_points(referrer, 100)
|
42
|
+
LoyaltyRef.earn_points(referee, 50)
|
43
|
+
end
|
44
|
+
|
45
|
+
# Points expiration (in days)
|
46
|
+
config.points_expiry_days = 90
|
47
|
+
|
48
|
+
# Referral code length
|
49
|
+
config.referral_code_length = 8
|
50
|
+
|
51
|
+
# Admin dashboard route path
|
52
|
+
config.admin_route_path = "/loyalty"
|
53
|
+
|
54
|
+
# Device tracking and analytics
|
55
|
+
config.enable_device_tracking = true # Enable device detection
|
56
|
+
config.collect_geo_location = false # Enable geo-location (requires user consent)
|
57
|
+
|
58
|
+
# Optional: Callback when user's tier changes
|
59
|
+
config.on_tier_changed = ->(user, old_tier, new_tier) do
|
60
|
+
# Example: Send notification email
|
61
|
+
# UserMailer.tier_changed(user, old_tier, new_tier).deliver_later
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class User < ApplicationRecord
|
4
|
+
# Add these columns to your users table:
|
5
|
+
# add_column :users, :referral_code, :string
|
6
|
+
# add_column :users, :tier, :string
|
7
|
+
# add_index :users, :referral_code, unique: true
|
8
|
+
|
9
|
+
has_many :loyalty_ref_transactions, class_name: 'LoyaltyRef::Transaction', as: :user
|
10
|
+
has_many :referrals, class_name: 'User', foreign_key: 'referrer_id'
|
11
|
+
belongs_to :referrer, class_name: 'User', optional: true
|
12
|
+
|
13
|
+
before_create :generate_referral_code
|
14
|
+
|
15
|
+
def points_balance
|
16
|
+
LoyaltyRef.balance(self)
|
17
|
+
end
|
18
|
+
|
19
|
+
def current_tier
|
20
|
+
LoyaltyRef.tier(self)
|
21
|
+
end
|
22
|
+
|
23
|
+
def referral_link
|
24
|
+
"#{Rails.application.routes.url_helpers.root_url}?ref=#{referral_code}"
|
25
|
+
end
|
26
|
+
|
27
|
+
def total_referrals
|
28
|
+
referrals.count
|
29
|
+
end
|
30
|
+
|
31
|
+
def successful_referrals
|
32
|
+
referrals.joins(:loyalty_ref_transactions).distinct.count
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def generate_referral_code
|
38
|
+
return if referral_code.present?
|
39
|
+
|
40
|
+
loop do
|
41
|
+
self.referral_code = SecureRandom.alphanumeric(LoyaltyRef.configuration.referral_code_length).upcase
|
42
|
+
break unless User.exists?(referral_code: referral_code)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module LoyaltyRef
|
4
|
+
class Configuration
|
5
|
+
attr_accessor :reference_class_name,
|
6
|
+
:earning_rule,
|
7
|
+
:redeem_rule,
|
8
|
+
:tier_thresholds,
|
9
|
+
:reward_modifier,
|
10
|
+
:referral_reward,
|
11
|
+
:on_tier_changed,
|
12
|
+
:points_expiry_days,
|
13
|
+
:referral_code_length,
|
14
|
+
:admin_route_path,
|
15
|
+
:enable_device_tracking,
|
16
|
+
:collect_geo_location
|
17
|
+
|
18
|
+
def initialize
|
19
|
+
@reference_class_name = "User"
|
20
|
+
@tier_thresholds = {
|
21
|
+
"Silver" => 500,
|
22
|
+
"Gold" => 1000,
|
23
|
+
"Platinum" => 2500
|
24
|
+
}
|
25
|
+
@points_expiry_days = 90
|
26
|
+
@referral_code_length = 8
|
27
|
+
@admin_route_path = "/loyalty"
|
28
|
+
@enable_device_tracking = true
|
29
|
+
@collect_geo_location = false # Default to false for privacy
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rails/engine"
|
4
|
+
|
5
|
+
module LoyaltyRef
|
6
|
+
class Engine < ::Rails::Engine
|
7
|
+
isolate_namespace LoyaltyRef
|
8
|
+
|
9
|
+
config.generators do |g|
|
10
|
+
g.test_framework :rspec, fixture: false
|
11
|
+
g.fixture_replacement :factory_bot, dir: "spec/factories"
|
12
|
+
g.assets false
|
13
|
+
g.helper false
|
14
|
+
end
|
15
|
+
|
16
|
+
initializer "loyalty_ref.assets" do |app|
|
17
|
+
app.config.assets.precompile += %w[loyalty_ref_manifest.js]
|
18
|
+
end
|
19
|
+
|
20
|
+
initializer "loyalty_ref.routes" do |app|
|
21
|
+
app.routes.prepend do
|
22
|
+
mount LoyaltyRef::Engine => LoyaltyRef.configuration.admin_route_path
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
initializer "loyalty_ref.migrations" do |app|
|
27
|
+
unless app.root.to_s.match root.to_s
|
28
|
+
config.paths["db/migrate"].expanded.each do |expanded_path|
|
29
|
+
app.config.paths["db/migrate"] << expanded_path
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,109 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module LoyaltyRef
|
4
|
+
class ReferralLog < ActiveRecord::Base
|
5
|
+
self.table_name = "loyalty_ref_referral_logs"
|
6
|
+
|
7
|
+
belongs_to :referee, polymorphic: true, optional: true
|
8
|
+
|
9
|
+
validates :referral_code, presence: true
|
10
|
+
validates :clicked_at, presence: true
|
11
|
+
|
12
|
+
scope :clicked, -> { where.not(clicked_at: nil) }
|
13
|
+
scope :signed_up, -> { where.not(signed_up_at: nil) }
|
14
|
+
scope :converted, -> { where.not(signed_up_at: nil).where.not(referee_id: nil) }
|
15
|
+
|
16
|
+
def converted?
|
17
|
+
signed_up_at.present? && referee_id.present?
|
18
|
+
end
|
19
|
+
|
20
|
+
def conversion_rate
|
21
|
+
return 0 if clicked_count.zero?
|
22
|
+
(converted_count.to_f / clicked_count * 100).round(2)
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.clicked_count
|
26
|
+
clicked.count
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.converted_count
|
30
|
+
converted.count
|
31
|
+
end
|
32
|
+
|
33
|
+
def self.conversion_rate
|
34
|
+
return 0 if clicked_count.zero?
|
35
|
+
(converted_count.to_f / clicked_count * 100).round(2)
|
36
|
+
end
|
37
|
+
|
38
|
+
# Device detection methods - use stored data or fallback to user_agent parsing
|
39
|
+
def device_type
|
40
|
+
return self[:device_type] if self[:device_type].present?
|
41
|
+
return detect_device_type_from_user_agent if user_agent.present?
|
42
|
+
nil
|
43
|
+
end
|
44
|
+
|
45
|
+
def browser
|
46
|
+
return self[:browser] if self[:browser].present?
|
47
|
+
return detect_browser_from_user_agent if user_agent.present?
|
48
|
+
nil
|
49
|
+
end
|
50
|
+
|
51
|
+
# Geo-location methods (if using a geo service)
|
52
|
+
def country
|
53
|
+
return geo_data["country"] if geo_data&.dig("country").present?
|
54
|
+
return nil unless ip_address.present?
|
55
|
+
# You can integrate with services like MaxMind GeoIP2 or similar
|
56
|
+
# For now, return nil - users can implement their own geo service
|
57
|
+
nil
|
58
|
+
end
|
59
|
+
|
60
|
+
def city
|
61
|
+
return geo_data["city"] if geo_data&.dig("city").present?
|
62
|
+
return nil unless ip_address.present?
|
63
|
+
# Implement with your preferred geo service
|
64
|
+
nil
|
65
|
+
end
|
66
|
+
|
67
|
+
def latitude
|
68
|
+
geo_data&.dig("latitude")
|
69
|
+
end
|
70
|
+
|
71
|
+
def longitude
|
72
|
+
geo_data&.dig("longitude")
|
73
|
+
end
|
74
|
+
|
75
|
+
private
|
76
|
+
|
77
|
+
def detect_device_type_from_user_agent
|
78
|
+
return nil unless user_agent.present?
|
79
|
+
|
80
|
+
user_agent = user_agent.downcase
|
81
|
+
|
82
|
+
if user_agent.include?("mobile") || user_agent.include?("android") || user_agent.include?("iphone")
|
83
|
+
"mobile"
|
84
|
+
elsif user_agent.include?("tablet") || user_agent.include?("ipad")
|
85
|
+
"tablet"
|
86
|
+
else
|
87
|
+
"desktop"
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def detect_browser_from_user_agent
|
92
|
+
return nil unless user_agent.present?
|
93
|
+
|
94
|
+
user_agent = user_agent.downcase
|
95
|
+
|
96
|
+
if user_agent.include?("chrome")
|
97
|
+
"Chrome"
|
98
|
+
elsif user_agent.include?("firefox")
|
99
|
+
"Firefox"
|
100
|
+
elsif user_agent.include?("safari")
|
101
|
+
"Safari"
|
102
|
+
elsif user_agent.include?("edge")
|
103
|
+
"Edge"
|
104
|
+
else
|
105
|
+
"Other"
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module LoyaltyRef
|
4
|
+
class Transaction < ActiveRecord::Base
|
5
|
+
self.table_name = "loyalty_ref_transactions"
|
6
|
+
|
7
|
+
belongs_to :user, polymorphic: true
|
8
|
+
|
9
|
+
validates :points, presence: true, numericality: { other_than: 0 }
|
10
|
+
validates :transaction_type, presence: true, inclusion: { in: %w[earn redeem adjust] }
|
11
|
+
|
12
|
+
scope :earned, -> { where(transaction_type: "earn") }
|
13
|
+
scope :redeemed, -> { where(transaction_type: "redeem") }
|
14
|
+
scope :active, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) }
|
15
|
+
scope :expired, -> { where("expires_at IS NOT NULL AND expires_at <= ?", Time.current) }
|
16
|
+
|
17
|
+
def expired?
|
18
|
+
expires_at.present? && expires_at <= Time.current
|
19
|
+
end
|
20
|
+
|
21
|
+
def active?
|
22
|
+
!expired?
|
23
|
+
end
|
24
|
+
|
25
|
+
def earned?
|
26
|
+
transaction_type == "earn"
|
27
|
+
end
|
28
|
+
|
29
|
+
def redeemed?
|
30
|
+
transaction_type == "redeem"
|
31
|
+
end
|
32
|
+
|
33
|
+
def adjusted?
|
34
|
+
transaction_type == "adjust"
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
data/lib/loyalty_ref.rb
ADDED
@@ -0,0 +1,191 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rails"
|
4
|
+
require "active_record"
|
5
|
+
require "action_view"
|
6
|
+
require "action_pack"
|
7
|
+
|
8
|
+
require_relative "loyalty_ref/version"
|
9
|
+
require_relative "loyalty_ref/engine"
|
10
|
+
require_relative "loyalty_ref/configuration"
|
11
|
+
require_relative "loyalty_ref/models/transaction"
|
12
|
+
require_relative "loyalty_ref/models/referral_log"
|
13
|
+
|
14
|
+
module LoyaltyRef
|
15
|
+
class << self
|
16
|
+
def configure
|
17
|
+
yield configuration
|
18
|
+
end
|
19
|
+
|
20
|
+
def configuration
|
21
|
+
@configuration ||= Configuration.new
|
22
|
+
end
|
23
|
+
|
24
|
+
def earn_points(user, amount, event: nil)
|
25
|
+
return false unless user && amount.positive?
|
26
|
+
|
27
|
+
# Apply reward modifier based on tier
|
28
|
+
modifier = configuration.reward_modifier&.call(user) || 1.0
|
29
|
+
final_amount = (amount * modifier).round
|
30
|
+
|
31
|
+
# Calculate expiration date
|
32
|
+
expiry_days = configuration.points_expiry_days
|
33
|
+
expires_at = expiry_days ? Time.current + expiry_days.days : nil
|
34
|
+
|
35
|
+
# Create transaction
|
36
|
+
transaction = Transaction.create!(
|
37
|
+
user: user,
|
38
|
+
points: final_amount,
|
39
|
+
transaction_type: "earn",
|
40
|
+
event_data: event&.as_json,
|
41
|
+
expires_at: expires_at,
|
42
|
+
description: "Earned #{final_amount} points"
|
43
|
+
)
|
44
|
+
|
45
|
+
# Update user tier if needed
|
46
|
+
update_user_tier(user)
|
47
|
+
|
48
|
+
transaction
|
49
|
+
rescue => e
|
50
|
+
Rails.logger.error "LoyaltyRef: Failed to earn points: #{e.message}"
|
51
|
+
false
|
52
|
+
end
|
53
|
+
|
54
|
+
def redeem_points(user, points, offer: nil)
|
55
|
+
return false unless user && points.positive?
|
56
|
+
|
57
|
+
current_balance = balance(user)
|
58
|
+
return false if current_balance < points
|
59
|
+
|
60
|
+
# Calculate cost using custom rule if provided
|
61
|
+
cost = if configuration.redeem_rule
|
62
|
+
configuration.redeem_rule.call(user, offer)
|
63
|
+
else
|
64
|
+
points
|
65
|
+
end
|
66
|
+
|
67
|
+
# Create transaction
|
68
|
+
transaction = Transaction.create!(
|
69
|
+
user: user,
|
70
|
+
points: -cost,
|
71
|
+
transaction_type: "redeem",
|
72
|
+
offer_data: offer&.as_json,
|
73
|
+
description: "Redeemed #{cost} points"
|
74
|
+
)
|
75
|
+
|
76
|
+
transaction
|
77
|
+
rescue => e
|
78
|
+
Rails.logger.error "LoyaltyRef: Failed to redeem points: #{e.message}"
|
79
|
+
false
|
80
|
+
end
|
81
|
+
|
82
|
+
def balance(user)
|
83
|
+
return 0 unless user
|
84
|
+
|
85
|
+
Transaction.where(user: user)
|
86
|
+
.where("expires_at IS NULL OR expires_at > ?", Time.current)
|
87
|
+
.sum(:points)
|
88
|
+
end
|
89
|
+
|
90
|
+
def tier(user)
|
91
|
+
return nil unless user && configuration.tier_thresholds.present?
|
92
|
+
|
93
|
+
balance = self.balance(user)
|
94
|
+
thresholds = configuration.tier_thresholds
|
95
|
+
|
96
|
+
# Find the highest tier the user qualifies for
|
97
|
+
thresholds.sort_by { |_, points| points }.reverse.each do |tier_name, required_points|
|
98
|
+
return tier_name if balance >= required_points
|
99
|
+
end
|
100
|
+
|
101
|
+
nil
|
102
|
+
end
|
103
|
+
|
104
|
+
def track_referral(ref_code:, user_agent: nil, ip_address: nil, referrer: nil, device_data: nil)
|
105
|
+
return false unless ref_code.present?
|
106
|
+
|
107
|
+
# Extract device and geo data from device_data parameter
|
108
|
+
device_type = device_data&.dig("device_type")
|
109
|
+
browser = device_data&.dig("browser")
|
110
|
+
geo_data = device_data&.dig("geo_data")
|
111
|
+
device_info = device_data&.except("device_type", "browser", "geo_data", "collected_at")
|
112
|
+
|
113
|
+
ReferralLog.create!(
|
114
|
+
referral_code: ref_code,
|
115
|
+
user_agent: user_agent,
|
116
|
+
ip_address: ip_address,
|
117
|
+
referrer: referrer,
|
118
|
+
clicked_at: Time.current,
|
119
|
+
device_type: device_type,
|
120
|
+
browser: browser,
|
121
|
+
geo_data: geo_data,
|
122
|
+
device_data: device_info
|
123
|
+
)
|
124
|
+
rescue => e
|
125
|
+
Rails.logger.error "LoyaltyRef: Failed to track referral: #{e.message}"
|
126
|
+
false
|
127
|
+
end
|
128
|
+
|
129
|
+
def process_referral_signup(referee, ref_code)
|
130
|
+
return false unless referee && ref_code.present?
|
131
|
+
|
132
|
+
# Find the referral log
|
133
|
+
referral_log = ReferralLog.where(referral_code: ref_code)
|
134
|
+
.where(referee_id: nil)
|
135
|
+
.order(clicked_at: :desc)
|
136
|
+
.first
|
137
|
+
|
138
|
+
return false unless referral_log
|
139
|
+
|
140
|
+
# Find the referrer
|
141
|
+
referrer = find_user_by_referral_code(ref_code)
|
142
|
+
return false unless referrer
|
143
|
+
|
144
|
+
# Update referral log
|
145
|
+
referral_log.update!(
|
146
|
+
referee: referee,
|
147
|
+
signed_up_at: Time.current
|
148
|
+
)
|
149
|
+
|
150
|
+
# Update referee's referrer
|
151
|
+
referee.update!(referrer: referrer)
|
152
|
+
|
153
|
+
# Process referral rewards
|
154
|
+
if configuration.referral_reward
|
155
|
+
configuration.referral_reward.call(referrer, referee)
|
156
|
+
end
|
157
|
+
|
158
|
+
true
|
159
|
+
rescue => e
|
160
|
+
Rails.logger.error "LoyaltyRef: Failed to process referral signup: #{e.message}"
|
161
|
+
false
|
162
|
+
end
|
163
|
+
|
164
|
+
private
|
165
|
+
|
166
|
+
def update_user_tier(user)
|
167
|
+
old_tier = user.tier
|
168
|
+
new_tier = tier(user)
|
169
|
+
|
170
|
+
return if old_tier == new_tier
|
171
|
+
|
172
|
+
# Update user's tier in database
|
173
|
+
user.update!(tier: new_tier)
|
174
|
+
|
175
|
+
# Call tier change callback if configured
|
176
|
+
if configuration.on_tier_changed
|
177
|
+
configuration.on_tier_changed.call(user, old_tier, new_tier)
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
def find_user_by_referral_code(code)
|
182
|
+
return nil unless configuration.reference_class_name
|
183
|
+
|
184
|
+
user_class = configuration.reference_class_name.constantize
|
185
|
+
user_class.find_by(referral_code: code)
|
186
|
+
rescue NameError
|
187
|
+
Rails.logger.error "LoyaltyRef: User class '#{configuration.reference_class_name}' not found"
|
188
|
+
nil
|
189
|
+
end
|
190
|
+
end
|
191
|
+
end
|