onboard_on_rails 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.
Files changed (62) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +235 -0
  3. data/Rakefile +10 -0
  4. data/app/assets/javascripts/onboard_on_rails/admin.js +306 -0
  5. data/app/assets/javascripts/onboard_on_rails/client.js +622 -0
  6. data/app/assets/stylesheets/onboard_on_rails/admin.css +963 -0
  7. data/app/assets/stylesheets/onboard_on_rails/client.css +228 -0
  8. data/app/controllers/onboard_on_rails/admin/base_controller.rb +30 -0
  9. data/app/controllers/onboard_on_rails/admin/lessons_controller.rb +45 -0
  10. data/app/controllers/onboard_on_rails/admin/stats_controller.rb +29 -0
  11. data/app/controllers/onboard_on_rails/admin/steps_controller.rb +57 -0
  12. data/app/controllers/onboard_on_rails/admin/tours_controller.rb +88 -0
  13. data/app/controllers/onboard_on_rails/api/base_controller.rb +26 -0
  14. data/app/controllers/onboard_on_rails/api/completions_controller.rb +30 -0
  15. data/app/controllers/onboard_on_rails/api/events_controller.rb +19 -0
  16. data/app/controllers/onboard_on_rails/api/tours_controller.rb +58 -0
  17. data/app/controllers/onboard_on_rails/application_controller.rb +5 -0
  18. data/app/controllers/onboard_on_rails/selector_picker_controller.rb +25 -0
  19. data/app/helpers/onboard_on_rails/meta_tags_helper.rb +25 -0
  20. data/app/models/onboard_on_rails/application_record.rb +5 -0
  21. data/app/models/onboard_on_rails/completion.rb +18 -0
  22. data/app/models/onboard_on_rails/concerns/segment_evaluator.rb +64 -0
  23. data/app/models/onboard_on_rails/concerns/url_matchable.rb +49 -0
  24. data/app/models/onboard_on_rails/event.rb +11 -0
  25. data/app/models/onboard_on_rails/step.rb +21 -0
  26. data/app/models/onboard_on_rails/tour.rb +33 -0
  27. data/app/services/onboard_on_rails/ab_assigner.rb +21 -0
  28. data/app/services/onboard_on_rails/completions_csv_exporter.rb +81 -0
  29. data/app/services/onboard_on_rails/self_tour_seeder.rb +278 -0
  30. data/app/services/onboard_on_rails/stats_calculator.rb +39 -0
  31. data/app/services/onboard_on_rails/tour_copier.rb +31 -0
  32. data/app/services/onboard_on_rails/tour_matcher.rb +117 -0
  33. data/app/views/layouts/onboard_on_rails/admin.html.erb +43 -0
  34. data/app/views/onboard_on_rails/admin/lessons/index.html.erb +45 -0
  35. data/app/views/onboard_on_rails/admin/stats/show.html.erb +190 -0
  36. data/app/views/onboard_on_rails/admin/steps/_form.html.erb +155 -0
  37. data/app/views/onboard_on_rails/admin/steps/_preview.html.erb +58 -0
  38. data/app/views/onboard_on_rails/admin/steps/edit.html.erb +18 -0
  39. data/app/views/onboard_on_rails/admin/steps/new.html.erb +10 -0
  40. data/app/views/onboard_on_rails/admin/tours/_form.html.erb +146 -0
  41. data/app/views/onboard_on_rails/admin/tours/_tour.html.erb +20 -0
  42. data/app/views/onboard_on_rails/admin/tours/edit.html.erb +48 -0
  43. data/app/views/onboard_on_rails/admin/tours/index.html.erb +40 -0
  44. data/app/views/onboard_on_rails/admin/tours/new.html.erb +10 -0
  45. data/app/views/onboard_on_rails/selector_picker/show.html.erb +62 -0
  46. data/config/locales/en.yml +265 -0
  47. data/config/locales/ru.yml +328 -0
  48. data/config/routes.rb +29 -0
  49. data/db/migrate/20260403000001_create_onboard_on_rails_tours.rb +25 -0
  50. data/db/migrate/20260403000002_create_onboard_on_rails_steps.rb +20 -0
  51. data/db/migrate/20260403000003_create_onboard_on_rails_completions.rb +19 -0
  52. data/db/migrate/20260403000004_create_onboard_on_rails_events.rb +13 -0
  53. data/db/migrate/20260404000001_add_matched_urls_to_onboard_on_rails_completions.rb +5 -0
  54. data/db/migrate/20260404000002_add_complete_on_target_click_to_onboard_on_rails_steps.rb +5 -0
  55. data/db/migrate/20260404000003_add_device_type_to_onboard_on_rails_tours.rb +5 -0
  56. data/db/migrate/20260414000001_add_overlay_enabled_to_onboard_on_rails_tours.rb +5 -0
  57. data/lib/onboard_on_rails/attribute_definition.rb +3 -0
  58. data/lib/onboard_on_rails/configuration.rb +79 -0
  59. data/lib/onboard_on_rails/engine.rb +29 -0
  60. data/lib/onboard_on_rails/version.rb +3 -0
  61. data/lib/onboard_on_rails.rb +24 -0
  62. metadata +171 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6db171d145c948dd206a246da8de2c21c4e9abfb9f7d7f078a090c47009df1a7
4
+ data.tar.gz: 3192fe22e98c26a5792e7cd13e41e9f526fbb39528ca8bae907b8dc472d5c286
5
+ SHA512:
6
+ metadata.gz: 57245caf7f6477dd5b290260410beca6d57aff53149acee4c1b3c710811b871520c295a4d5cde5eac388c0ea1b4101ae98550c7e265d309a094f81121291f60a
7
+ data.tar.gz: 86748d9f85f060cdd897be576acdb0561dd08afec36fe3e5e67d1b5d97490757f89b7bca4720d574660b21decb898007f7aa37cac5bd9890a4c798bf0a8a6f68
data/README.md ADDED
@@ -0,0 +1,235 @@
1
+ # OnboardOnRails
2
+
3
+ A universal onboarding tour engine for Ruby on Rails. Mount a full-featured admin panel into your app and create interactive product tours — no front-end framework required.
4
+
5
+ ## Features
6
+
7
+ - **Admin Panel** — create and manage tours, steps, and lessons from a browser UI
8
+ - **Visual Selector Picker** — point-and-click CSS selector builder (iframe-based)
9
+ - **4 Themes** — Tooltip, Modal, Banner, Slideout — configurable per tour and per step
10
+ - **A/B Testing** — split users into deterministic groups, compare completion rates
11
+ - **User Targeting** — DSL for registering targetable attributes with 15 operators (eq, starts_with, contains, matches, in, gt, etc.)
12
+ - **Scheduling** — set start/end dates for time-limited tours
13
+ - **Frequency Control** — once, every session, or always
14
+ - **Trigger Types** — auto (page load), event-based, or manual via API
15
+ - **Device Targeting** — run tours on all devices, desktop only, or mobile only
16
+ - **Theming** — configurable accent color and default font applied to admin panel and tour defaults
17
+ - **Overlay Toggle** — per-tour backdrop on/off
18
+ - **SSR + SPA support** — vanilla JS client works with Turbo, React, or classic Rails
19
+ - **i18n** — English and Russian out of the box, with per-user locale resolution
20
+ - **Self-Tour Lessons** — built-in interactive tutorials that teach the admin panel itself
21
+ - **Statistics** — completion rates, drop-off per step, A/B breakdown
22
+
23
+ ## Requirements
24
+
25
+ - Ruby >= 3.1
26
+ - Rails >= 7.0
27
+ - PostgreSQL (jsonb columns for url_pattern, segment_rules, style_overrides)
28
+
29
+ ## Quick Installation
30
+
31
+ ### 1. Add the gem
32
+
33
+ ```ruby
34
+ # Gemfile
35
+ gem "onboard_on_rails"
36
+ ```
37
+
38
+ ```bash
39
+ bundle install
40
+ ```
41
+
42
+ ### 2. Mount the engine
43
+
44
+ ```ruby
45
+ # config/routes.rb
46
+ Rails.application.routes.draw do
47
+ mount OnboardOnRails::Engine => "/onboard"
48
+ # ...
49
+ end
50
+ ```
51
+
52
+ ### 3. Run migrations
53
+
54
+ ```bash
55
+ bin/rails onboard_on_rails:install:migrations
56
+ bin/rails db:migrate
57
+ ```
58
+
59
+ ### 4. Create initializer
60
+
61
+ ```ruby
62
+ # config/initializers/onboard_on_rails.rb
63
+ OnboardOnRails.configure do |config|
64
+ config.user_class = "User"
65
+ config.current_user_method = :current_user
66
+
67
+ config.admin_auth = ->(controller) {
68
+ controller.current_user&.admin?
69
+ }
70
+
71
+ # Optional: brand the admin panel and tour defaults
72
+ config.accent_color = "#2d3436" # hex, 6 digits — dark/light/rgba variants derived automatically
73
+ config.default_font = "Inter, sans-serif" # applied to tours when no style override is set
74
+
75
+ # Optional: resolve the locale for each user (defaults to "ru")
76
+ config.user_locale = ->(user) { user.locale || "en" }
77
+
78
+ config.register_attribute :email, type: :string, label: "Email" do |user|
79
+ user.email
80
+ end
81
+
82
+ config.register_attribute :plan, type: :string, label: "Plan", values: ["free", "pro", "enterprise"] do |user|
83
+ user.plan
84
+ end
85
+ end
86
+ ```
87
+
88
+ ### 5. Add meta tags and assets to your layout
89
+
90
+ ```erb
91
+ <%# app/views/layouts/application.html.erb %>
92
+ <head>
93
+ <%= onboard_on_rails_meta_tags %>
94
+ <%= javascript_include_tag "onboard_on_rails/client" %>
95
+ <%= stylesheet_link_tag "onboard_on_rails/client" %>
96
+ </head>
97
+ ```
98
+
99
+ ## Configuration
100
+
101
+ | Option | Type | Default | Description |
102
+ |---|---|---|---|
103
+ | `user_class` | String | `"User"` | ActiveRecord model representing users |
104
+ | `admin_auth` | Lambda | `->(_) { true }` | Receives controller, returns true/false for admin access |
105
+ | `current_user_method` | Symbol | `:current_user` | Method name on your ApplicationController that returns the current user |
106
+ | `user_locale` | Lambda | `->(_) { "ru" }` | Receives the user, returns a locale code used to render tours |
107
+ | `accent_color` | String | `"#2d3436"` | 6-digit hex color; drives admin panel branding and default tour accents (light/dark/rgba variants are derived automatically) |
108
+ | `default_font` | String | `nil` | CSS `font-family` used as a default on tours when no style override is set |
109
+ | `register_attribute` | DSL | — | Register a user attribute for targeting (see below) |
110
+
111
+ ## User Targeting
112
+
113
+ Register attributes that can be used for targeting in the admin panel. Each attribute needs a type, label, and a block that extracts the value from the user object.
114
+
115
+ ```ruby
116
+ OnboardOnRails.configure do |config|
117
+ # String attributes — supports: eq, not_eq, in, not_in, starts_with, ends_with,
118
+ # contains, not_contains, matches (regex), length_gt, length_lt
119
+ config.register_attribute :email, type: :string, label: "Email",
120
+ description: "User email address" do |user|
121
+ user.email
122
+ end
123
+
124
+ # String with predefined values — admin sees a dropdown instead of text input
125
+ config.register_attribute :plan, type: :string, label: "Plan",
126
+ description: "Subscription plan",
127
+ values: ["free", "pro", "enterprise"] do |user|
128
+ user.plan
129
+ end
130
+
131
+ # Number attributes — supports: eq, not_eq, in, not_in, gt, lt, gte, lte
132
+ config.register_attribute :account_id, type: :number, label: "Account ID",
133
+ description: "ID of the user's account" do |user|
134
+ user.account_id
135
+ end
136
+
137
+ # Boolean attributes — supports: eq (true/false dropdown)
138
+ config.register_attribute :admin, type: :boolean, label: "Admin?",
139
+ description: "Whether the user is an admin" do |user|
140
+ user.admin?
141
+ end
142
+ end
143
+ ```
144
+
145
+ ### Parameters
146
+
147
+ | Parameter | Required | Description |
148
+ |---|---|---|
149
+ | `key` | yes | Symbol identifier (first argument) |
150
+ | `type` | yes | `:string`, `:number`, or `:boolean` |
151
+ | `label` | yes | Display name in the admin panel |
152
+ | `description` | no | Help text shown below the attribute selector |
153
+ | `values` | no | Array of allowed values (renders as dropdown in admin) |
154
+ | `block` | yes | `\|user\| -> value` — extracts the attribute value |
155
+
156
+ ### Examples
157
+
158
+ Show a tour to users whose email starts with "foo":
159
+ - Attribute: `email`, Operator: `starts_with`, Value: `foo`
160
+
161
+ Show a tour to specific accounts:
162
+ - Attribute: `account_id`, Operator: `in`, Value: `123, 456, 789`
163
+
164
+ Show a tour to users with names longer than 10 characters:
165
+ - Attribute: `name`, Operator: `length_gt`, Value: `10`
166
+
167
+ Combine multiple conditions with AND/OR logic in the admin panel.
168
+
169
+ ## Usage
170
+
171
+ 1. Open the admin panel at `/onboard/admin`
172
+ 2. Click **New Tour**, fill in name, URL pattern, theme, and trigger settings
173
+ 3. Add steps — set CSS selector, placement, title, and body text
174
+ 4. Use the visual selector picker to choose elements on your pages
175
+ 5. Set the tour status to **Active**
176
+ 6. Visit the target page as a logged-in user — the tour starts automatically
177
+
178
+ ## Self-Tour Lessons
179
+
180
+ OnboardOnRails ships with built-in tutorials that teach admins how to use the panel:
181
+
182
+ - **Lesson 1**: Overview of the admin panel
183
+ - **Lesson 2**: Creating and configuring a tour
184
+ - **Lesson 3**: Adding and styling steps
185
+
186
+ To create them, go to `/onboard/admin/lessons` and click **Create Lessons**, or call:
187
+
188
+ ```ruby
189
+ OnboardOnRails::SelfTourSeeder.seed!
190
+ ```
191
+
192
+ ## API
193
+
194
+ ### Client-side (JavaScript)
195
+
196
+ ```javascript
197
+ // Track a custom event
198
+ OnboardOnRails.trackEvent("first_project_created", { project_id: 42 });
199
+ ```
200
+
201
+ ### Server-side (Ruby)
202
+
203
+ ```ruby
204
+ # Track an event for a user
205
+ OnboardOnRails.track_event(user, "subscription_activated", { plan: "pro" })
206
+ ```
207
+
208
+ ### REST Endpoints
209
+
210
+ | Method | Path | Description |
211
+ |---|---|---|
212
+ | GET | `/onboard/api/tours?url=/dashboard` | Fetch matching tour for URL |
213
+ | POST | `/onboard/api/completions` | Create/update completion record |
214
+ | POST | `/onboard/api/events` | Track a custom event |
215
+
216
+ ## Tech Stack
217
+
218
+ | Layer | Technology |
219
+ |---|---|
220
+ | Backend | Rails Engine (mountable, isolated namespace) |
221
+ | Database | PostgreSQL with jsonb columns |
222
+ | Admin JS | Vanilla JavaScript (no framework) |
223
+ | Client JS | Vanilla JavaScript (no framework) |
224
+ | Asset pipeline | Sprockets |
225
+ | Styling | Plain CSS with custom properties |
226
+ | i18n | Rails I18n (en, ru) |
227
+ | Testing | RSpec + FactoryBot |
228
+
229
+ ## Detailed Documentation
230
+
231
+ See [docs/setup.md](docs/setup.md) for comprehensive Russian documentation covering authentication, targeting, theming, A/B testing, API reference, and more.
232
+
233
+ ## License
234
+
235
+ MIT
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ require "bundler/setup"
2
+ require "bundler/gem_tasks"
3
+
4
+ APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
5
+ load "rails/tasks/engine.rake"
6
+ load "rails/tasks/statistics.rake"
7
+
8
+ require "rspec/core/rake_task"
9
+ RSpec::Core::RakeTask.new(:spec)
10
+ task default: :spec
@@ -0,0 +1,306 @@
1
+ // OnboardOnRails Admin Bundle
2
+ // Compatible with both Sprockets and Propshaft
3
+
4
+ // === Step Preview Controller ===
5
+ document.addEventListener("DOMContentLoaded", function() {
6
+ // Find the step edit form — only run on step edit pages
7
+ var form = document.querySelector("[data-controller='step-preview']");
8
+ if (!form) return;
9
+
10
+ function updatePreview() {
11
+ var title = document.querySelector("[data-step-preview-target='title']");
12
+ var body = document.querySelector("[data-step-preview-target='body']");
13
+ var previewTitle = document.querySelector("[data-step-preview-target='previewTitle']");
14
+ var previewBody = document.querySelector("[data-step-preview-target='previewBody']");
15
+ var previewButton = document.querySelector("[data-step-preview-target='previewButton']");
16
+ var bgColor = document.querySelector("[data-step-preview-target='bgColor']");
17
+ var textColor = document.querySelector("[data-step-preview-target='textColor']");
18
+ var buttonColor = document.querySelector("[data-step-preview-target='buttonColor']");
19
+ var fontFamily = document.querySelector("[data-step-preview-target='fontFamily']");
20
+ var fontSize = document.querySelector("[data-step-preview-target='fontSize']");
21
+ var borderRadius = document.querySelector("[data-step-preview-target='borderRadius']");
22
+ var tooltipBody = document.querySelector("[data-step-preview-target='tooltipBody']");
23
+
24
+ if (previewTitle && title) previewTitle.textContent = title.value || "Step Title";
25
+ if (previewBody && body) previewBody.textContent = body.value || "Step description goes here...";
26
+
27
+ if (tooltipBody) {
28
+ if (bgColor) tooltipBody.style.background = bgColor.value;
29
+ if (fontFamily) tooltipBody.style.fontFamily = fontFamily.value;
30
+ if (borderRadius) tooltipBody.style.borderRadius = borderRadius.value;
31
+ }
32
+ if (previewTitle && textColor) previewTitle.style.color = textColor.value;
33
+ if (previewBody && textColor) previewBody.style.color = textColor.value;
34
+ if (previewBody && fontSize) previewBody.style.fontSize = fontSize.value;
35
+ if (previewButton && buttonColor) previewButton.style.background = buttonColor.value;
36
+ }
37
+
38
+ // Attach listeners to all form inputs
39
+ form.querySelectorAll("input, textarea, select").forEach(function(el) {
40
+ el.addEventListener("input", updatePreview);
41
+ el.addEventListener("change", updatePreview);
42
+ });
43
+ });
44
+
45
+ // === Segment Rules Controller ===
46
+ document.addEventListener("DOMContentLoaded", function() {
47
+ var wrapper = document.querySelector("[data-controller='segment-rules']");
48
+ if (!wrapper) return;
49
+
50
+ var container = wrapper.querySelector("[data-segment-rules-target='container']");
51
+ var output = wrapper.querySelector("[data-segment-rules-target='output']");
52
+ var logicSelect = wrapper.querySelector("[data-segment-rules-target='logic']");
53
+ var addButton = wrapper.querySelector("[data-action*='segment-rules#add']");
54
+
55
+ var availableAttributes = [];
56
+ var operatorLabels = {};
57
+ var placeholders = {};
58
+
59
+ try { availableAttributes = JSON.parse(wrapper.getAttribute("data-available-attributes") || "[]"); } catch(e) {}
60
+ try { operatorLabels = JSON.parse(wrapper.getAttribute("data-operator-labels") || "{}"); } catch(e) {}
61
+ try { placeholders = JSON.parse(wrapper.getAttribute("data-placeholders") || "{}"); } catch(e) {}
62
+
63
+ var OPERATORS_BY_TYPE = {
64
+ string: ["eq", "not_eq", "in", "not_in", "starts_with", "ends_with", "contains", "not_contains", "matches", "length_gt", "length_lt"],
65
+ number: ["eq", "not_eq", "in", "not_in", "gt", "lt", "gte", "lte"],
66
+ boolean: ["eq"]
67
+ };
68
+
69
+ function findAttr(key) {
70
+ for (var i = 0; i < availableAttributes.length; i++) {
71
+ if (availableAttributes[i].key === key) return availableAttributes[i];
72
+ }
73
+ return null;
74
+ }
75
+
76
+ function serialize() {
77
+ var rows = container.querySelectorAll(".oor-segment-row");
78
+ var conditions = Array.from(rows).map(function(row) {
79
+ var op = row.querySelector(".oor-segment-op").value;
80
+ var val = row.querySelector(".oor-segment-val").value;
81
+ if (op === "in" || op === "not_in") {
82
+ val = val.split(",").map(function(v) { return v.trim(); });
83
+ }
84
+ return {
85
+ attribute: row.querySelector(".oor-segment-attr").value,
86
+ operator: op,
87
+ value: val
88
+ };
89
+ });
90
+ var logic = logicSelect ? logicSelect.value : "and";
91
+ output.value = JSON.stringify({ conditions: conditions, logic: logic });
92
+ }
93
+
94
+ function buildAttrSelect(selectedKey) {
95
+ var html = "";
96
+ for (var i = 0; i < availableAttributes.length; i++) {
97
+ var a = availableAttributes[i];
98
+ var sel = a.key === selectedKey ? " selected" : "";
99
+ html += '<option value="' + a.key + '"' + sel + '>' + a.label + '</option>';
100
+ }
101
+ return html;
102
+ }
103
+
104
+ function buildOpSelect(type, selectedOp) {
105
+ var ops = OPERATORS_BY_TYPE[type] || OPERATORS_BY_TYPE.string;
106
+ var html = "";
107
+ for (var i = 0; i < ops.length; i++) {
108
+ var op = ops[i];
109
+ var label = operatorLabels[op] || op;
110
+ var sel = op === selectedOp ? " selected" : "";
111
+ html += '<option value="' + op + '"' + sel + '>' + label + '</option>';
112
+ }
113
+ return html;
114
+ }
115
+
116
+ function buildValueInput(attrDef, operator, value) {
117
+ if (operator === "eq" && attrDef && attrDef.type === "boolean") {
118
+ return '<select class="oor-segment-val oor-form-control" style="flex:1;">' +
119
+ '<option value="true"' + (value === "true" ? " selected" : "") + '>' + (placeholders.boolean_true || "true") + '</option>' +
120
+ '<option value="false"' + (value !== "true" ? " selected" : "") + '>' + (placeholders.boolean_false || "false") + '</option>' +
121
+ '</select>';
122
+ }
123
+ if ((operator === "eq" || operator === "not_eq") && attrDef && attrDef.values && attrDef.values.length > 0) {
124
+ var html = '<select class="oor-segment-val oor-form-control" style="flex:1;">';
125
+ for (var i = 0; i < attrDef.values.length; i++) {
126
+ var v = attrDef.values[i];
127
+ var sel = v === value ? " selected" : "";
128
+ html += '<option value="' + v + '"' + sel + '>' + v + '</option>';
129
+ }
130
+ html += '</select>';
131
+ return html;
132
+ }
133
+ var placeholder = placeholders.value || "value";
134
+ if (operator === "in" || operator === "not_in") placeholder = placeholders.in_values || "values separated by comma";
135
+ if (operator === "matches") placeholder = placeholders.regex || "regular expression";
136
+ var displayValue = Array.isArray(value) ? value.join(", ") : (value || "");
137
+ return '<input type="text" placeholder="' + placeholder + '" value="' + displayValue + '" class="oor-segment-val oor-form-control" style="flex:1;">';
138
+ }
139
+
140
+ function addConditionRow(condition) {
141
+ var attrKey = condition.attribute || (availableAttributes[0] ? availableAttributes[0].key : "");
142
+ var attrDef = findAttr(attrKey);
143
+ var type = attrDef ? attrDef.type : "string";
144
+ var operator = condition.operator || "eq";
145
+
146
+ var row = document.createElement("div");
147
+ row.className = "oor-segment-row";
148
+
149
+ row.innerHTML =
150
+ '<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">' +
151
+ '<select class="oor-segment-attr oor-form-control" style="flex:1;min-width:120px;">' + buildAttrSelect(attrKey) + '</select>' +
152
+ '<select class="oor-segment-op oor-form-control" style="flex:1;min-width:120px;">' + buildOpSelect(type, operator) + '</select>' +
153
+ buildValueInput(attrDef, operator, Array.isArray(condition.value) ? condition.value.join(", ") : (condition.value || "")) +
154
+ '<button type="button" class="oor-segment-remove oor-btn oor-btn--sm oor-btn--danger" style="flex-shrink:0;">&times;</button>' +
155
+ '</div>' +
156
+ (attrDef && attrDef.description ? '<div class="oor-segment-description">' + attrDef.description + '</div>' : '');
157
+
158
+ row.querySelector(".oor-segment-remove").addEventListener("click", function() {
159
+ row.remove();
160
+ serialize();
161
+ });
162
+
163
+ var attrSelect = row.querySelector(".oor-segment-attr");
164
+ attrSelect.addEventListener("change", function() {
165
+ var newAttrDef = findAttr(attrSelect.value);
166
+ var newType = newAttrDef ? newAttrDef.type : "string";
167
+ var opSelect = row.querySelector(".oor-segment-op");
168
+ opSelect.innerHTML = buildOpSelect(newType, "eq");
169
+ var oldValEl = row.querySelector(".oor-segment-val");
170
+ var temp = document.createElement("div");
171
+ temp.innerHTML = buildValueInput(newAttrDef, "eq", "");
172
+ oldValEl.parentNode.replaceChild(temp.firstChild, oldValEl);
173
+ var descEl = row.querySelector(".oor-segment-description");
174
+ if (descEl) descEl.remove();
175
+ if (newAttrDef && newAttrDef.description) {
176
+ var newDesc = document.createElement("div");
177
+ newDesc.className = "oor-segment-description";
178
+ newDesc.textContent = newAttrDef.description;
179
+ row.appendChild(newDesc);
180
+ }
181
+ bindRowEvents(row);
182
+ serialize();
183
+ });
184
+
185
+ var opSelect = row.querySelector(".oor-segment-op");
186
+ opSelect.addEventListener("change", function() {
187
+ var currentAttrDef = findAttr(attrSelect.value);
188
+ var oldValEl = row.querySelector(".oor-segment-val");
189
+ var temp = document.createElement("div");
190
+ temp.innerHTML = buildValueInput(currentAttrDef, opSelect.value, "");
191
+ oldValEl.parentNode.replaceChild(temp.firstChild, oldValEl);
192
+ bindRowEvents(row);
193
+ serialize();
194
+ });
195
+
196
+ bindRowEvents(row);
197
+ container.appendChild(row);
198
+ serialize();
199
+ }
200
+
201
+ function bindRowEvents(row) {
202
+ row.querySelectorAll(".oor-segment-val, .oor-segment-op, .oor-segment-attr").forEach(function(el) {
203
+ el.removeEventListener("change", serialize);
204
+ el.removeEventListener("input", serialize);
205
+ el.addEventListener("change", serialize);
206
+ el.addEventListener("input", serialize);
207
+ });
208
+ }
209
+
210
+ function loadExisting() {
211
+ var data;
212
+ try { data = JSON.parse(output.value || "{}"); } catch(e) { data = {}; }
213
+ if (data.conditions && data.conditions.length > 0) {
214
+ data.conditions.forEach(function(c) { addConditionRow(c); });
215
+ }
216
+ if (data.logic && logicSelect) logicSelect.value = data.logic;
217
+ }
218
+
219
+ if (addButton) {
220
+ addButton.addEventListener("click", function(e) {
221
+ e.preventDefault();
222
+ addConditionRow({ attribute: "", operator: "eq", value: "" });
223
+ });
224
+ }
225
+
226
+ if (logicSelect) {
227
+ logicSelect.addEventListener("change", serialize);
228
+ }
229
+
230
+ loadExisting();
231
+ });
232
+
233
+ // === Sortable Controller ===
234
+ document.addEventListener("DOMContentLoaded", function() {
235
+ var wrapper = document.querySelector("[data-controller='sortable']");
236
+ if (!wrapper) return;
237
+
238
+ var sortableUrl = wrapper.getAttribute("data-sortable-url-value");
239
+ var draggedItem = null;
240
+
241
+ wrapper.querySelectorAll("[data-step-id]").forEach(function(item) {
242
+ item.draggable = true;
243
+
244
+ item.addEventListener("dragstart", function(e) {
245
+ draggedItem = e.currentTarget;
246
+ e.currentTarget.style.opacity = "0.4";
247
+ e.dataTransfer.effectAllowed = "move";
248
+ });
249
+
250
+ item.addEventListener("dragover", function(e) {
251
+ e.preventDefault();
252
+ e.dataTransfer.dropEffect = "move";
253
+ var target = e.currentTarget;
254
+ if (target !== draggedItem) {
255
+ var rect = target.getBoundingClientRect();
256
+ if (e.clientY < rect.top + rect.height / 2) {
257
+ target.parentNode.insertBefore(draggedItem, target);
258
+ } else {
259
+ target.parentNode.insertBefore(draggedItem, target.nextSibling);
260
+ }
261
+ }
262
+ });
263
+
264
+ item.addEventListener("drop", function(e) {
265
+ e.preventDefault();
266
+ saveOrder();
267
+ });
268
+
269
+ item.addEventListener("dragend", function(e) {
270
+ e.currentTarget.style.opacity = "1";
271
+ draggedItem = null;
272
+ });
273
+ });
274
+
275
+ function saveOrder() {
276
+ var items = wrapper.querySelectorAll("[data-step-id]");
277
+ var order = Array.from(items).map(function(item, i) {
278
+ return { id: item.dataset.stepId, position: i + 1 };
279
+ });
280
+ if (sortableUrl) {
281
+ var csrfToken = document.querySelector('meta[name="csrf-token"]');
282
+ fetch(sortableUrl, {
283
+ method: "PATCH",
284
+ headers: {
285
+ "Content-Type": "application/json",
286
+ "X-CSRF-Token": csrfToken ? csrfToken.content : ""
287
+ },
288
+ body: JSON.stringify({ order: order })
289
+ });
290
+ }
291
+ }
292
+ });
293
+
294
+ // === Selector Picker Controller ===
295
+ // Register on window directly (not inside DOMContentLoaded) so it survives Turbo Drive navigations
296
+ window.addEventListener("message", function(event) {
297
+ if (event.data && event.data.type === "oor-selector-picked") {
298
+ var input = document.querySelector("input[name='step[selector]']")
299
+ || document.getElementById("step_selector");
300
+ if (input) {
301
+ input.value = event.data.selector;
302
+ input.dispatchEvent(new Event("input"));
303
+ input.dispatchEvent(new Event("change"));
304
+ }
305
+ }
306
+ });