affiliate_tracker 0.3.1

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.
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module AffiliateTracker
6
+ class ClicksController < ApplicationController
7
+ skip_before_action :verify_authenticity_token, only: [:redirect]
8
+
9
+ def redirect
10
+ payload = params[:payload]
11
+ signature = params[:s]
12
+
13
+ begin
14
+ data = UrlGenerator.decode(payload, signature)
15
+ destination_url = data[:destination_url]
16
+ metadata = data[:metadata]
17
+
18
+ # Record the click
19
+ record_click(destination_url, metadata)
20
+
21
+ # Normalize URL protocol
22
+ destination_url = "https://#{destination_url}" unless destination_url.match?(%r{\A[a-zA-Z][a-zA-Z0-9+\-.]*://})
23
+
24
+ # Build final URL with UTM parameters
25
+ final_url = append_utm_params(destination_url, metadata)
26
+
27
+ # Redirect to destination (302 so browsers don't cache and we can re-track clicks)
28
+ redirect_to final_url, allow_other_host: true, status: :found
29
+ rescue AffiliateTracker::Error => e
30
+ Rails.logger.warn "[AffiliateTracker] Invalid tracking URL: #{e.message} from #{request.remote_ip}"
31
+ redirect_to AffiliateTracker.configuration.resolve_fallback_url(payload), allow_other_host: true
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def record_click(destination_url, metadata)
38
+ dedup_key = "affiliate_tracker:#{request.remote_ip}:#{destination_url}"
39
+
40
+ # Deduplication using Rails.cache (5 seconds window)
41
+ return if Rails.cache.exist?(dedup_key)
42
+
43
+ Rails.cache.write(dedup_key, true, expires_in: 5.seconds)
44
+
45
+ click = Click.create!(
46
+ destination_url: destination_url,
47
+ ip_address: anonymize_ip(request.remote_ip),
48
+ user_agent: request.user_agent&.truncate(500),
49
+ referer: request.referer&.truncate(500),
50
+ metadata: metadata,
51
+ clicked_at: Time.current
52
+ )
53
+
54
+ # Call custom handler if configured
55
+ if (handler = AffiliateTracker.configuration.after_click)
56
+ handler.call(click)
57
+ end
58
+ rescue StandardError => e
59
+ Rails.logger.error "[AffiliateTracker] Failed to record click: #{e.message}"
60
+ end
61
+
62
+ def append_utm_params(url, metadata)
63
+ uri = URI.parse(url)
64
+ params = URI.decode_www_form(uri.query || '')
65
+
66
+ # Add ref and UTM params (metadata overrides defaults)
67
+ config = AffiliateTracker.configuration
68
+ tracking_params = {
69
+ 'ref' => config.ref_param,
70
+ 'utm_source' => metadata['utm_source'] || config.utm_source,
71
+ 'utm_medium' => metadata['utm_medium'] || config.utm_medium,
72
+ 'utm_campaign' => metadata['campaign'],
73
+ 'utm_content' => metadata['shop']
74
+ }.compact
75
+
76
+ # Merge with existing params (don't overwrite if already present)
77
+ existing_keys = params.map(&:first)
78
+ tracking_params.each do |key, value|
79
+ params << [key, value] unless existing_keys.include?(key)
80
+ end
81
+
82
+ uri.query = URI.encode_www_form(params) if params.any?
83
+ uri.to_s
84
+ rescue URI::InvalidURIError
85
+ url
86
+ end
87
+
88
+ def anonymize_ip(ip)
89
+ return nil if ip.blank?
90
+
91
+ if ip.include?(':')
92
+ # IPv6: zero the last 80 bits (last 5 groups)
93
+ groups = ip.split(':')
94
+ if groups.size >= 8
95
+ (3..7).each { |i| groups[i] = '0' }
96
+ groups.join(':')
97
+ else
98
+ ip
99
+ end
100
+ else
101
+ # IPv4: zero the last octet
102
+ parts = ip.split('.')
103
+ return ip unless parts.size == 4
104
+
105
+ parts[3] = '0'
106
+ parts.join('.')
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AffiliateTracker
4
+ class DashboardController < ApplicationController
5
+ before_action :authenticate!
6
+
7
+ def index
8
+ @stats = {
9
+ total_clicks: Click.count,
10
+ today_clicks: Click.where("clicked_at >= ?", Time.current.beginning_of_day).count,
11
+ week_clicks: Click.where("clicked_at >= ?", 1.week.ago).count,
12
+ unique_destinations: Click.distinct.count(:destination_url)
13
+ }
14
+
15
+ @recent_clicks = Click.order(clicked_at: :desc).limit(20)
16
+ @top_destinations = Click.group(:destination_url)
17
+ .order("count_all DESC")
18
+ .limit(10)
19
+ .count
20
+ end
21
+
22
+ private
23
+
24
+ def authenticate!
25
+ if (auth = AffiliateTracker.configuration.authenticate_dashboard)
26
+ instance_exec(&auth)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AffiliateTracker
4
+ class Click < ActiveRecord::Base
5
+ self.table_name = "affiliate_tracker_clicks"
6
+
7
+ validates :destination_url, presence: true
8
+ validates :clicked_at, presence: true
9
+
10
+ # metadata is native JSON column - no serialize needed in Rails 8
11
+
12
+ scope :today, -> { where("clicked_at >= ?", Time.current.beginning_of_day) }
13
+ scope :this_week, -> { where("clicked_at >= ?", 1.week.ago) }
14
+ scope :this_month, -> { where("clicked_at >= ?", 1.month.ago) }
15
+
16
+ def domain
17
+ URI.parse(destination_url).host
18
+ rescue URI::InvalidURIError
19
+ nil
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,91 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Affiliate Tracker Dashboard</title>
5
+ <style>
6
+ * { box-sizing: border-box; margin: 0; padding: 0; }
7
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; }
8
+ .container { max-width: 1200px; margin: 0 auto; padding: 20px; }
9
+ h1 { font-size: 24px; margin-bottom: 20px; color: #111; }
10
+ .stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
11
+ .stat { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
12
+ .stat-value { font-size: 32px; font-weight: 700; color: #111; }
13
+ .stat-label { font-size: 14px; color: #666; margin-top: 4px; }
14
+ .section { background: white; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); padding: 20px; margin-bottom: 24px; }
15
+ .section h2 { font-size: 18px; margin-bottom: 16px; color: #111; }
16
+ table { width: 100%; border-collapse: collapse; }
17
+ th, td { text-align: left; padding: 12px; border-bottom: 1px solid #eee; }
18
+ th { font-weight: 600; color: #666; font-size: 12px; text-transform: uppercase; }
19
+ td { font-size: 14px; }
20
+ .url { max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
21
+ .time { color: #666; }
22
+ .badge { display: inline-block; padding: 2px 8px; background: #e0f2e9; color: #0d6939; border-radius: 4px; font-size: 12px; }
23
+ </style>
24
+ </head>
25
+ <body>
26
+ <div class="container">
27
+ <h1>Affiliate Tracker</h1>
28
+
29
+ <div class="stats">
30
+ <div class="stat">
31
+ <div class="stat-value"><%= @stats[:total_clicks] %></div>
32
+ <div class="stat-label">Total Clicks</div>
33
+ </div>
34
+ <div class="stat">
35
+ <div class="stat-value"><%= @stats[:today_clicks] %></div>
36
+ <div class="stat-label">Today</div>
37
+ </div>
38
+ <div class="stat">
39
+ <div class="stat-value"><%= @stats[:week_clicks] %></div>
40
+ <div class="stat-label">This Week</div>
41
+ </div>
42
+ <div class="stat">
43
+ <div class="stat-value"><%= @stats[:unique_destinations] %></div>
44
+ <div class="stat-label">Unique Destinations</div>
45
+ </div>
46
+ </div>
47
+
48
+ <div class="section">
49
+ <h2>Top Destinations</h2>
50
+ <table>
51
+ <thead>
52
+ <tr>
53
+ <th>URL</th>
54
+ <th>Clicks</th>
55
+ </tr>
56
+ </thead>
57
+ <tbody>
58
+ <% @top_destinations.each do |url, count| %>
59
+ <tr>
60
+ <td class="url"><%= url %></td>
61
+ <td><span class="badge"><%= count %></span></td>
62
+ </tr>
63
+ <% end %>
64
+ </tbody>
65
+ </table>
66
+ </div>
67
+
68
+ <div class="section">
69
+ <h2>Recent Clicks</h2>
70
+ <table>
71
+ <thead>
72
+ <tr>
73
+ <th>Time</th>
74
+ <th>Destination</th>
75
+ <th>Metadata</th>
76
+ </tr>
77
+ </thead>
78
+ <tbody>
79
+ <% @recent_clicks.each do |click| %>
80
+ <tr>
81
+ <td class="time"><%= click.clicked_at.strftime("%Y-%m-%d %H:%M") %></td>
82
+ <td class="url"><%= click.destination_url %></td>
83
+ <td><code><%= click.metadata.to_json if click.metadata.present? %></code></td>
84
+ </tr>
85
+ <% end %>
86
+ </tbody>
87
+ </table>
88
+ </div>
89
+ </div>
90
+ </body>
91
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ AffiliateTracker::Engine.routes.draw do
4
+ get "/dashboard", to: "dashboard#index", as: :dashboard
5
+ get "/:payload", to: "clicks#redirect", as: :track
6
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AffiliateTracker
4
+ class Configuration
5
+ # Dashboard authentication proc (optional)
6
+ attr_accessor :authenticate_dashboard
7
+
8
+ # Custom click handler (optional)
9
+ attr_accessor :after_click
10
+
11
+ # Default UTM source (your brand name)
12
+ attr_accessor :utm_source
13
+
14
+ # Default UTM medium
15
+ attr_accessor :utm_medium
16
+
17
+ # Referral parameter (e.g., "partnerJan" adds ?ref=partnerJan)
18
+ attr_accessor :ref_param
19
+
20
+ # Default metadata proc - called when generating URLs
21
+ # Example: -> { { user_id: Current.user&.id } }
22
+ attr_accessor :default_metadata
23
+
24
+ # Fallback URL when signature is missing or invalid.
25
+ # Can be a String (static URL) or a Proc that receives the decoded payload Hash.
26
+ # The payload is decoded WITHOUT signature verification, so treat it as untrusted.
27
+ # Default: "/" (homepage)
28
+ #
29
+ # Examples:
30
+ # config.fallback_url = "/oops"
31
+ # config.fallback_url = ->(payload) { payload&.dig("shop") ? "/#{payload["shop"]}" : "/" }
32
+ attr_accessor :fallback_url
33
+
34
+ def initialize
35
+ @authenticate_dashboard = nil
36
+ @after_click = nil
37
+ @utm_source = 'affiliate'
38
+ @utm_medium = 'referral'
39
+ @ref_param = nil
40
+ @default_metadata = nil
41
+ @fallback_url = '/'
42
+ end
43
+
44
+ # Resolve fallback URL from config. Safely decodes payload (unverified) and
45
+ # passes it to the proc. Returns "/" if anything goes wrong.
46
+ def resolve_fallback_url(raw_payload)
47
+ payload_data = decode_payload_unsafe(raw_payload)
48
+
49
+ if @fallback_url.respond_to?(:call)
50
+ result = @fallback_url.call(payload_data)
51
+ result.presence || '/'
52
+ else
53
+ @fallback_url.to_s.presence || '/'
54
+ end
55
+ rescue StandardError
56
+ '/'
57
+ end
58
+
59
+ def resolve_default_metadata
60
+ return {} unless @default_metadata.respond_to?(:call)
61
+
62
+ result = @default_metadata.call
63
+ result.is_a?(Hash) ? result : {}
64
+ rescue StandardError
65
+ {}
66
+ end
67
+
68
+ def base_url
69
+ # Try routes first, then ActionMailer::Base as fallback (Rails 8 way)
70
+ options = Rails.application.routes.default_url_options.presence ||
71
+ ActionMailer::Base.default_url_options.presence ||
72
+ {}
73
+
74
+ host = options[:host]
75
+ unless host
76
+ raise Error,
77
+ "Set config.action_mailer.default_url_options = { host: 'example.com' } in config/environments/*.rb"
78
+ end
79
+
80
+ protocol = options[:protocol] || 'https'
81
+ "#{protocol}://#{host}"
82
+ end
83
+
84
+ def secret_key
85
+ Rails.application.key_generator.generate_key('affiliate_tracker', 32)
86
+ end
87
+
88
+ private
89
+
90
+ # Decode Base64 payload without verifying signature.
91
+ # Used only for building fallback URLs — treat result as untrusted.
92
+ def decode_payload_unsafe(raw_payload)
93
+ return nil if raw_payload.blank?
94
+
95
+ JSON.parse(Base64.urlsafe_decode64(raw_payload))
96
+ rescue StandardError
97
+ nil
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AffiliateTracker
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace AffiliateTracker
6
+
7
+ initializer "affiliate_tracker.helpers" do
8
+ ActiveSupport.on_load(:action_view) do
9
+ include AffiliateTracker::ViewHelpers
10
+ end
11
+
12
+ ActiveSupport.on_load(:action_mailer) do
13
+ include AffiliateTracker::ViewHelpers
14
+ end
15
+ end
16
+ end
17
+ end
18
+
19
+ require_relative "view_helpers"
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'openssl'
5
+ require 'json'
6
+ require 'active_support/security_utils'
7
+
8
+ module AffiliateTracker
9
+ class UrlGenerator
10
+ attr_reader :destination_url, :metadata
11
+
12
+ def initialize(destination_url, metadata = {})
13
+ @destination_url = normalize_url(destination_url)
14
+ @metadata = metadata.transform_keys(&:to_s)
15
+ end
16
+
17
+ def generate
18
+ payload = encode_payload
19
+ signature = sign(payload)
20
+ "#{base_url}#{route_path}/#{payload}?s=#{signature}"
21
+ end
22
+
23
+ private
24
+
25
+ def normalize_url(url)
26
+ return url if url.blank? || url.match?(%r{\A[a-zA-Z][a-zA-Z0-9+\-.]*://})
27
+ "https://#{url}"
28
+ end
29
+
30
+ def encode_payload
31
+ data = { u: destination_url }.merge(metadata)
32
+ Base64.urlsafe_encode64(data.to_json, padding: false)
33
+ end
34
+
35
+ def sign(payload)
36
+ OpenSSL::HMAC.hexdigest('SHA256', secret_key, payload).first(32)
37
+ end
38
+
39
+ def base_url
40
+ AffiliateTracker.configuration.base_url or raise Error, 'base_url not configured'
41
+ end
42
+
43
+ def route_path
44
+ '/a'
45
+ end
46
+
47
+ def secret_key
48
+ AffiliateTracker.configuration.secret_key or raise Error, 'secret_key not configured'
49
+ end
50
+
51
+ class << self
52
+ def decode(payload, signature)
53
+ raise Error, 'Missing payload' if payload.nil? || payload.empty?
54
+ raise Error, 'Missing signature' if signature.nil? || signature.empty?
55
+
56
+ expected_sig = OpenSSL::HMAC.hexdigest(
57
+ 'SHA256',
58
+ AffiliateTracker.configuration.secret_key,
59
+ payload
60
+ ).first(32)
61
+
62
+ raise Error, 'Invalid signature' unless ActiveSupport::SecurityUtils.secure_compare(expected_sig, signature)
63
+
64
+ data = JSON.parse(Base64.urlsafe_decode64(payload))
65
+ {
66
+ destination_url: data.delete('u'),
67
+ metadata: data
68
+ }
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AffiliateTracker
4
+ VERSION = "0.3.1"
5
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AffiliateTracker
4
+ module ViewHelpers
5
+ # Generate a trackable affiliate link
6
+ #
7
+ # @param url [String] Destination URL
8
+ # @param text [String] Link text
9
+ # @param options [Hash] Tracking metadata (stored in click record)
10
+ #
11
+ # Common tracking options:
12
+ # user_id: - ID of user who will click (for attribution)
13
+ # shop_id: - Shop/store identifier
14
+ # promotion_id: - Specific promotion being clicked
15
+ # campaign: - Campaign name (e.g., "daily_digest", "weekly_email")
16
+ #
17
+ # HTML options (applied to <a> tag, not stored):
18
+ # class:, id:, style:, target:, rel:, data:, aria:
19
+ #
20
+ # Examples:
21
+ # # Basic link
22
+ # affiliate_link("https://shop.com", "Shop Now")
23
+ #
24
+ # # With user tracking (recommended for emails)
25
+ # affiliate_link("https://shop.com", "Shop Now", user_id: @user.id, campaign: "email")
26
+ #
27
+ # # Full tracking
28
+ # affiliate_link("https://shop.com", "View Deal",
29
+ # user_id: @user.id,
30
+ # shop_id: @shop.id,
31
+ # promotion_id: @promotion.id,
32
+ # campaign: "daily_digest")
33
+ #
34
+ # # With block
35
+ # affiliate_link("https://shop.com", user_id: @user.id) { "Shop Now" }
36
+ #
37
+ def affiliate_link(url, text_or_options = nil, options = {}, html_options = {}, &block)
38
+ if block_given?
39
+ options = text_or_options || {}
40
+ text = capture(&block)
41
+ else
42
+ text = text_or_options
43
+ end
44
+
45
+ html_keys = [:class, :id, :style, :target, :rel, :data, :aria]
46
+ tracking_url = AffiliateTracker.url(url, **options.except(*html_keys))
47
+ html_opts = { href: tracking_url, target: "_blank", rel: "noopener" }
48
+ html_opts.merge!(html_options)
49
+ html_opts.merge!(options.slice(*html_keys))
50
+
51
+ content_tag(:a, text, html_opts)
52
+ end
53
+
54
+ # Generate just the tracking URL (useful for emails or manual links)
55
+ #
56
+ # @param url [String] Destination URL
57
+ # @param metadata [Hash] Tracking data (see affiliate_link for common options)
58
+ #
59
+ # Examples:
60
+ # affiliate_url("https://shop.com")
61
+ # affiliate_url("https://shop.com", user_id: @user.id, campaign: "email_weekly")
62
+ #
63
+ def affiliate_url(url, **metadata)
64
+ AffiliateTracker.url(url, **metadata)
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "affiliate_tracker/version"
4
+ require_relative "affiliate_tracker/configuration"
5
+
6
+ module AffiliateTracker
7
+ class Error < StandardError; end
8
+
9
+ class << self
10
+ attr_writer :configuration
11
+
12
+ def configuration
13
+ @configuration ||= Configuration.new
14
+ end
15
+
16
+ def configure
17
+ yield(configuration)
18
+ end
19
+
20
+ # Generate a trackable affiliate URL
21
+ def track_url(destination_url, metadata = {})
22
+ merged_metadata = configuration.resolve_default_metadata.merge(metadata)
23
+ UrlGenerator.new(destination_url, merged_metadata).generate
24
+ end
25
+
26
+ # Shorthand for track_url
27
+ def url(destination_url, **metadata)
28
+ track_url(destination_url, metadata)
29
+ end
30
+ end
31
+ end
32
+
33
+ require_relative "affiliate_tracker/engine" if defined?(Rails)
34
+ require_relative "affiliate_tracker/url_generator"
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module AffiliateTracker
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ include Rails::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ def self.next_migration_number(dirname)
14
+ ActiveRecord::Generators::Base.next_migration_number(dirname)
15
+ end
16
+
17
+ def copy_migration
18
+ migration_template "create_affiliate_tracker_clicks.rb.tt",
19
+ "db/migrate/create_affiliate_tracker_clicks.rb"
20
+ end
21
+
22
+ def create_initializer
23
+ template "initializer.rb", "config/initializers/affiliate_tracker.rb"
24
+ end
25
+
26
+ def mount_engine
27
+ routes_path = "config/routes.rb"
28
+ mount_line = 'mount AffiliateTracker::Engine, at: "/a"'
29
+
30
+ return if File.exist?(routes_path) && File.read(routes_path).include?(mount_line)
31
+
32
+ route mount_line
33
+ end
34
+
35
+ def show_readme
36
+ readme "README" if behavior == :invoke
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,19 @@
1
+
2
+ ===============================================================================
3
+
4
+ AffiliateTracker has been installed!
5
+
6
+ Next steps:
7
+ 1. Run migrations:
8
+ $ rails db:migrate
9
+
10
+ 2. Configure in config/initializers/affiliate_tracker.rb
11
+
12
+ 3. Use in your views/mailers:
13
+ <%= affiliate_link "https://shop.com", "Shop Now", shop_id: 1 %>
14
+ <%= affiliate_url "https://shop.com", campaign: "email" %>
15
+
16
+ 4. Access dashboard at:
17
+ /a/dashboard
18
+
19
+ ===============================================================================
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateAffiliateTrackerClicks < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :affiliate_tracker_clicks do |t|
6
+ t.string :destination_url, null: false
7
+ t.string :ip_address
8
+ t.string :user_agent
9
+ t.string :referer
10
+ t.json :metadata
11
+ t.datetime :clicked_at, null: false
12
+
13
+ t.timestamps
14
+ end
15
+
16
+ add_index :affiliate_tracker_clicks, :destination_url
17
+ add_index :affiliate_tracker_clicks, :clicked_at
18
+ add_index :affiliate_tracker_clicks, :created_at
19
+ end
20
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ AffiliateTracker.configure do |config|
4
+ # Your brand name (appears in utm_source)
5
+ # config.utm_source = "mybrand"
6
+
7
+ # Default medium (appears in utm_medium)
8
+ # config.utm_medium = "email"
9
+
10
+ # Referral param (adds ?ref=yourname to all links)
11
+ # config.ref_param = "yourname"
12
+
13
+ # Dashboard authentication (optional)
14
+ # config.authenticate_dashboard = -> {
15
+ # redirect_to main_app.login_path unless current_user&.admin?
16
+ # }
17
+
18
+ # Fallback URL for invalid/missing signatures (default: "/")
19
+ # String: static URL
20
+ # Proc: receives decoded payload Hash (unverified — treat as untrusted)
21
+ # config.fallback_url = "/"
22
+ # config.fallback_url = ->(payload) {
23
+ # slug = payload&.dig("shop")
24
+ # slug.present? ? "/#{slug}" : "/"
25
+ # }
26
+ end