product_tours 0.3.0 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ea098df5cd9d109566f8f7561a53e182932258742abd06b427596f2b8deb0574
4
- data.tar.gz: f7607d43d7cc8eee2174c9232279cf192f6f74d446b1f4c7da246be752f4d2fe
3
+ metadata.gz: bca51e6535dbdd38d6d683ee0ea95e430acbefd41479f356e1c0e7c330c64697
4
+ data.tar.gz: fce14b868cabf23946d5658aa5c9362d5b0e3df92e326b27f31164b9590e01cd
5
5
  SHA512:
6
- metadata.gz: d95991dc7543548549f11544d9f7c898b31a09fae6a06bd23c78e81df86937a2a5edca0c8127f6f7e51a10cd50b2c235fd19e87aa1fedaa8e54bcc8f66872d69
7
- data.tar.gz: 1f1cd6f01c210a157d67b1b736a9a0540be356aabc875eb4e0daac5e91e3f6864dd9995413b324a90400ac35d9a5cc83bc12792d0d832111bf43c147e66c5273
6
+ metadata.gz: 1dec9e04d694dd26b2baafbe9eb6226370ccb728c500c086ebc387e8cf6bdfa7433f5588772b9a9e61be26e2d5558af59dd4d096424a429cc9004abed73b3084
7
+ data.tar.gz: b86e2d428a86bb9708cbb709eb80e0e5de1ead8bf3a18d32e78c38d64a9e24ce7d41a2bbaea3ee7d214e8d4dadecc74bc35dce1ffcd74e570eb7a78ea3095281
data/AGENTS.md CHANGED
@@ -81,7 +81,8 @@ YouTube, Vimeo, Loom, Tella, Voomly, a direct MP4/WebM URL, or an uploaded file
81
81
 
82
82
  ### Lifecycle events
83
83
 
84
- Subscribe in an initializer; there is no callback config to set.
84
+ Subscribe in an initializer when your normal request middleware already sets
85
+ host identity:
85
86
 
86
87
  ```ruby
87
88
  ActiveSupport::Notifications.subscribe("product_tours.completed") do |*, payload|
@@ -91,6 +92,20 @@ end
91
92
 
92
93
  Names: `product_tours.viewed`, `product_tours.dismissed`, `product_tours.completed`, and `product_tours.unresolved_trigger` — the last one fires when a `data-product-tour` button names a key that does not resolve. Subscribe to it in development; it turns "my button does nothing" into a log line naming the key.
93
94
 
95
+ The public widget controller deliberately does not inherit the host's controller.
96
+ If that means a subscriber cannot see the host's current user/account context,
97
+ configure the request-aware lifecycle hook instead:
98
+
99
+ ```ruby
100
+ config.on_event = lambda do |name, payload, request|
101
+ user = request.env["warden"]&.user
102
+ ProductTourEventJob.perform_later(name, payload, user&.id)
103
+ end
104
+ ```
105
+
106
+ It receives only `viewed`, `dismissed`, and `completed`, runs inline, and logs
107
+ exceptions without breaking the visitor flow. Keep it fast or enqueue a job.
108
+
94
109
  ### Do not
95
110
 
96
111
  - **Do not copy the widget JavaScript into `app/javascript`, or add a `<script>` tag for it.** `product_tours_tag` renders what is needed and the engine serves the code. There is no build step and nothing for esbuild/importmap/Tailwind to know about.
@@ -101,7 +116,7 @@ Names: `product_tours.viewed`, `product_tours.dismissed`, `product_tours.complet
101
116
 
102
117
  ### Configuration
103
118
 
104
- There are five options. That is the whole surface.
119
+ There are seven options. That is the whole surface.
105
120
 
106
121
  | Option | Default | What it does |
107
122
  | --- | --- | --- |
@@ -109,6 +124,7 @@ There are five options. That is the whole surface.
109
124
  | `enabled` | everyone | Per-request gate for the widget and its endpoints |
110
125
  | `base_controller_class` | `ActionController::Base` | Controller the dashboard inherits. Name your admin's and it adopts that layout, helpers, authentication and request context. Public endpoints never inherit it. |
111
126
  | `admin_layout` | `product_tours/application` | Render the dashboard inside your admin shell |
127
+ | `on_event` | no-op | Handle lifecycle events with `(name, payload, request)` when host identity must be resolved from the raw request |
112
128
  | `mount_path` | `"/product_tours"` | Keep in sync with `mount_product_tours at:` |
113
129
  | `storage_service` | app default | Active Storage service for uploaded video (a `storage.yml` key) |
114
130
 
@@ -152,6 +168,6 @@ Conventions this codebase holds to — follow them rather than the first thing t
152
168
  - **Uploaded media streams through the engine's gate**, never a public blob URL.
153
169
  - **The CSP patch is additive.** It appends to existing sources and drops `'none'` rather than replacing a host's policy — do not let it start overwriting directives.
154
170
  - **The dummy app pins `config.active_job.queue_adapter = :test`.** Do not remove it or let it drift back to the `:async` default. Attaching a video enqueues Active Storage's analysis job, and `:async` runs it on a background thread that checks out its own connection — writes no test transaction covers, landing in the middle of whatever runs next. That is a suite that fails order-dependently in a test which never created a row, and it is miserable to trace back.
155
- - **`lib/product_tours/dashboard.css` is half shared.** Everything above the `GEM-SPECIFIC` banner is the design system all five gems in the family ship — the same tokens, the same `.page-head`/`.tabs`/`.filters`/`.card`/`.badge`/`button`, the same `.dashboard-shell` + `.record-row` + `.detail-panel` two-pane dashboard — identical in every repo apart from the `pt` prefix. Diff it against a sibling before changing it, and carry the change to the other four. Anything only this gem has goes below the banner. New dashboard markup reuses the shared class names rather than inventing a domain-specific one.
171
+ - **`lib/product_tours/dashboard.css` is half shared.** Everything above the `GEM-SPECIFIC` banner is the design system all five gems in the family ship — the same tokens, the same `.page-head`/`.tabs`/`.filters`/`.card`/`.badge`/`button`/`.status-switch`, the same `.dashboard-shell` + `.record-row` + `.detail-panel` two-pane dashboard — identical in every repo apart from the `pt` prefix. Diff it against a sibling before changing it, and carry the change to the other four. Anything only this gem has goes below the banner. New dashboard markup reuses the shared class names rather than inventing a domain-specific one.
156
172
  - Every user-facing change bumps `lib/product_tours/version.rb` and adds a `CHANGELOG.md` entry (Keep a Changelog format) that says what it costs, not only what it adds.
157
173
  - Commit messages are prose that explains the tradeoff — read `git log` before writing one.
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [Unreleased]
4
+
5
+ ## [0.3.1] - 2026-08-08
6
+
7
+ - **Hosts can handle lifecycle events with their own request context.** Set
8
+ `config.on_event` to a callable accepting `(name, payload, request)` when a
9
+ `viewed`, `dismissed`, or `completed` signal must resolve a signed session,
10
+ user, account, or tenant that deliberately does not belong in the gem. The
11
+ existing `ActiveSupport::Notifications` events and their minimal payloads are
12
+ unchanged. The hook runs inline, so slow work should be enqueued; exceptions
13
+ are logged and never turn a working tutorial action into a visitor-facing
14
+ failure.
15
+
3
16
  ## 0.3.0
4
17
 
5
18
  - **One design system across the family.** The stylesheet now opens with a
@@ -60,8 +73,6 @@
60
73
  `remove_index :product_tours_posts, :locale`.
61
74
  - A `BackboneTest` now fails the build on any of the above regressing.
62
75
 
63
- ## [Unreleased]
64
-
65
76
  ## [0.1.2] - 2026-08-04
66
77
 
67
78
  - Added `AGENTS.md`: install and integration instructions written for coding
@@ -122,6 +133,7 @@
122
133
  - Removed generic tutorial duplication now that translations provide the only
123
134
  intentional content-copying workflow.
124
135
 
125
- [Unreleased]: https://github.com/yshmarov/product_tours/compare/v0.1.1...HEAD
136
+ [Unreleased]: https://github.com/yshmarov/product_tours/compare/v0.3.1...HEAD
137
+ [0.3.1]: https://github.com/yshmarov/product_tours/compare/v0.3.0...v0.3.1
126
138
  [0.1.1]: https://github.com/yshmarov/product_tours/compare/v0.1.0...v0.1.1
127
139
  [0.1.0]: https://github.com/yshmarov/product_tours/releases/tag/v0.1.0
data/README.md CHANGED
@@ -228,6 +228,7 @@ Everything is optional — a development install works with zero config. In
228
228
  | `authorize_admin` | development only | **Who can manage content at the mount path** |
229
229
  | `base_controller_class` | `ActionController::Base` | Controller the dashboard inherits — name your admin's and it adopts its layout, helpers and auth |
230
230
  | `admin_layout` | gem layout | Just the shell, if you don't want the whole controller |
231
+ | `on_event` | no-op | Handle lifecycle events with the raw request and host-owned identity/account context |
231
232
  | `storage_service` | app default | Named Active Storage service for uploaded videos |
232
233
  | `mount_path` | `/product_tours` | Keep in sync only when mounting the engine manually |
233
234
 
@@ -236,6 +237,9 @@ ProductTours.configure do |config|
236
237
  config.enabled = ->(request) { request.env["warden"]&.user.present? }
237
238
  config.authorize_admin = ->(request) { request.env["warden"]&.user&.admin? }
238
239
  config.admin_layout = "admin/application"
240
+ config.on_event = lambda do |name, payload, request|
241
+ AnalyticsJob.perform_later(name, payload, request.session[:user_id])
242
+ end
239
243
  config.storage_service = :product_tours
240
244
  end
241
245
  ```
@@ -302,6 +306,20 @@ end
302
306
  Your subscriber can attach `Current.user` or account context. That identity does
303
307
  not need to become product-tour configuration.
304
308
 
309
+ If the engine's public controller does not run your host controller callbacks,
310
+ use `on_event` instead. It receives the same event name and payload plus the raw
311
+ request, so it can safely resolve the host's signed session:
312
+
313
+ ```ruby
314
+ config.on_event = lambda do |name, payload, request|
315
+ user = request.env["warden"]&.user
316
+ ProductTourEventJob.perform_later(name, payload, user&.id)
317
+ end
318
+ ```
319
+
320
+ The hook runs inline; enqueue slow work. Exceptions are logged and do not break
321
+ the visitor flow. `product_tours.unresolved_trigger` remains a notification only.
322
+
305
323
  ## Broken triggers fail loudly, not publicly
306
324
 
307
325
  `data-product-tour="key"` is a contract between code and dashboard content.
@@ -32,12 +32,20 @@ module ProductTours
32
32
  page_url: clean_page_url(params[:page_url]),
33
33
  source: params[:source].to_s.presence
34
34
  }
35
- ActiveSupport::Notifications.instrument("product_tours.#{action}", payload)
35
+ event_name = "product_tours.#{action}"
36
+ ActiveSupport::Notifications.instrument(event_name, payload)
37
+ notify_host(event_name, payload)
36
38
  head :no_content
37
39
  end
38
40
 
39
41
  private
40
42
 
43
+ def notify_host(event_name, payload)
44
+ ProductTours.config.on_event.call(event_name, payload, request)
45
+ rescue StandardError => e
46
+ Rails.logger.error("product_tours: on_event hook raised #{e.class}: #{e.message}")
47
+ end
48
+
41
49
  def post_payload(post)
42
50
  video = post.resolved_video
43
51
  video[:url] = media_path(post) if video&.dig(:kind) == 'upload'
@@ -21,6 +21,13 @@ ProductTours.configure do |config|
21
21
  # Tutorials resolve in the page's current I18n.locale and fall back to
22
22
  # I18n.default_locale when no translation exists.
23
23
 
24
+ # Optional lifecycle hook. Receives the event name, minimal payload, and raw
25
+ # request so the host can resolve its own user/account context. Runs inline;
26
+ # enqueue slow work. Hook errors are logged and never break the visitor flow.
27
+ # config.on_event = lambda do |name, payload, request|
28
+ # AnalyticsJob.perform_later(name, payload, request.session[:user_id])
29
+ # end
30
+
24
31
  # Optional dedicated Active Storage service for uploaded videos.
25
32
  # config.storage_service = :product_tours
26
33
 
@@ -7,7 +7,7 @@ module ProductTours
7
7
  DEFAULT_ADMIN_LAYOUT = 'product_tours/application'
8
8
 
9
9
  attr_accessor :enabled, :authorize_admin, :admin_layout, :mount_path,
10
- :storage_service
10
+ :storage_service, :on_event
11
11
 
12
12
  # The controller the DASHBOARD inherits from, as a String so it resolves
13
13
  # lazily rather than at config time. Default: a plain
@@ -31,6 +31,7 @@ module ProductTours
31
31
  @base_controller_class = 'ActionController::Base'
32
32
  @mount_path = '/product_tours'
33
33
  @storage_service = nil
34
+ @on_event = ->(_name, _payload, _request) {}
34
35
  end
35
36
 
36
37
  def widget_endpoint = "#{mount_path.to_s.chomp('/')}/widget"
@@ -144,6 +144,19 @@
144
144
  & .actions form { display: inline; }
145
145
  & .form-actions { padding-top: 12px; }
146
146
 
147
+ /* Status switch: one pill holding every state a record can be in, the state it
148
+ is in now lit. Each other segment is a submit button, so moving a record
149
+ takes one click and no JavaScript; the current segment is a `type="button"`
150
+ so clicking it cannot re-file the record it is already filed under. Two
151
+ states or five, the pill sizes itself. A gem tints the lit segment with its
152
+ own status colour — see the `GEM-SPECIFIC` section. */
153
+ & .status-switch { display: inline-flex; flex: 0 0 auto; margin: 0; padding: 3px; border: 1px solid var(--pt-border); border-radius: 999px; background: var(--pt-bg); }
154
+ & .status-switch button { min-height: 30px; padding: 0 12px; border: 0; border-radius: 999px; background: none; color: var(--pt-muted); font-size: 13px; font-weight: 700; white-space: nowrap; transition: background .16s ease, color .16s ease; }
155
+ & .status-switch button:hover { border: 0; background: color-mix(in srgb, var(--pt-muted) 12%, transparent); color: var(--pt-text); }
156
+ & .status-switch button.current { background: var(--pt-surface); color: var(--pt-text); box-shadow: 0 1px 2px rgba(15, 23, 42, .12); cursor: default; }
157
+ & .status-switch button.current:hover { background: var(--pt-surface); }
158
+ & .panel-head .status-switch { margin-left: auto; }
159
+
147
160
  & .card { overflow: hidden; border: 1px solid var(--pt-border); border-radius: 12px; background: var(--pt-surface); }
148
161
  & .card.pad { padding: 16px; overflow: visible; }
149
162
  & table { width: 100%; border-collapse: collapse; }
@@ -266,6 +279,7 @@
266
279
  & .dashboard-detail .detail-panel { min-height: calc(100vh - 64px); }
267
280
  & .mobile-back { display: block; flex: 0 0 auto; margin: 4px 0 10px; font-size: 13px; }
268
281
  & .panel-head h1 { font-size: 18px; }
282
+ & .status-switch button { padding: 0 9px; font-size: 12px; }
269
283
  & dl { grid-template-columns: 1fr; gap: 3px; }
270
284
  & dd { margin-bottom: 8px; }
271
285
  }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ProductTours
4
- VERSION = '0.3.0'
4
+ VERSION = '0.3.1'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: product_tours
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yaroslav Shmarov