product_tours 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 +41 -0
- data/MIT-LICENSE +19 -0
- data/README.md +209 -0
- data/Rakefile +11 -0
- data/app/controllers/product_tours/application_controller.rb +68 -0
- data/app/controllers/product_tours/media_controller.rb +14 -0
- data/app/controllers/product_tours/posts_controller.rb +230 -0
- data/app/controllers/product_tours/tours_controller.rb +69 -0
- data/app/controllers/product_tours/widgets_controller.rb +28 -0
- data/app/helpers/product_tours/posts_helper.rb +19 -0
- data/app/helpers/product_tours/widget_helper.rb +14 -0
- data/app/models/product_tours/application_record.rb +7 -0
- data/app/models/product_tours/post.rb +128 -0
- data/app/views/layouts/product_tours/application.html.erb +20 -0
- data/app/views/product_tours/posts/_form.html.erb +177 -0
- data/app/views/product_tours/posts/_post_panel.html.erb +111 -0
- data/app/views/product_tours/posts/edit.html.erb +5 -0
- data/app/views/product_tours/posts/index.html.erb +92 -0
- data/app/views/product_tours/posts/new.html.erb +5 -0
- data/app/views/product_tours/posts/show.html.erb +5 -0
- data/config/locales/product_tours.ar.yml +11 -0
- data/config/locales/product_tours.bg.yml +11 -0
- data/config/locales/product_tours.bn.yml +11 -0
- data/config/locales/product_tours.de.yml +11 -0
- data/config/locales/product_tours.el.yml +11 -0
- data/config/locales/product_tours.en.yml +11 -0
- data/config/locales/product_tours.es.yml +11 -0
- data/config/locales/product_tours.fr.yml +11 -0
- data/config/locales/product_tours.hi.yml +11 -0
- data/config/locales/product_tours.hr.yml +11 -0
- data/config/locales/product_tours.id.yml +11 -0
- data/config/locales/product_tours.it.yml +11 -0
- data/config/locales/product_tours.ja.yml +11 -0
- data/config/locales/product_tours.ko.yml +11 -0
- data/config/locales/product_tours.lb.yml +11 -0
- data/config/locales/product_tours.nl.yml +11 -0
- data/config/locales/product_tours.pl.yml +11 -0
- data/config/locales/product_tours.pt.yml +11 -0
- data/config/locales/product_tours.ro.yml +11 -0
- data/config/locales/product_tours.ru.yml +11 -0
- data/config/locales/product_tours.th.yml +11 -0
- data/config/locales/product_tours.tr.yml +11 -0
- data/config/locales/product_tours.uk.yml +11 -0
- data/config/locales/product_tours.ur.yml +11 -0
- data/config/locales/product_tours.vi.yml +11 -0
- data/config/locales/product_tours.zh-CN.yml +11 -0
- data/config/routes.rb +23 -0
- data/lib/generators/product_tours/install/install_generator.rb +44 -0
- data/lib/generators/product_tours/install/templates/create_product_tours_posts.rb.tt +29 -0
- data/lib/generators/product_tours/install/templates/initializer.rb +21 -0
- data/lib/product_tours/configuration.rb +18 -0
- data/lib/product_tours/content_security_policy.rb +24 -0
- data/lib/product_tours/dashboard.css +196 -0
- data/lib/product_tours/dashboard.js +229 -0
- data/lib/product_tours/engine.rb +28 -0
- data/lib/product_tours/errors.rb +14 -0
- data/lib/product_tours/seeds.rb +147 -0
- data/lib/product_tours/version.rb +5 -0
- data/lib/product_tours/video_metadata.rb +60 -0
- data/lib/product_tours/video_resolver.rb +129 -0
- data/lib/product_tours/widget.js +359 -0
- data/lib/product_tours/widget.rb +59 -0
- data/lib/product_tours.rb +35 -0
- data/lib/tasks/product_tours_tasks.rake +12 -0
- metadata +131 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 82d70df0c0b3463d2b4e49de5d2d8736c1ef50ab732a86bca87d5fcf2ebe0b51
|
|
4
|
+
data.tar.gz: 2e010ecadc7381f7176f6525cddc4ed93719dc07528fd4fb17d8c63852abcc24
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f89e331dd2c34ffb12821a5f9159abc39c8f21bd78c6577048cdccf9895bed7eab76048a165234c8dd612f9c22a959440fc5d4d644eaa36807c3c6173a12e8d0
|
|
7
|
+
data.tar.gz: 3a34a6f113643ba9076c69805e586ca073187124de304f51ef6ebf353b9baba42c3f8c7c0f4a59e8118e9044185adc36bc79b93f4f01334a222336b7eca2c537
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [0.1.0] - 2026-08-03
|
|
6
|
+
|
|
7
|
+
- Initial Rails engine, post dashboard, modal widget, video providers,
|
|
8
|
+
lifecycle notifications, installer, and demo seed task.
|
|
9
|
+
- Linked Post actions with same-modal navigation, an in-memory Back stack, and
|
|
10
|
+
self-explanatory action choices in the dashboard.
|
|
11
|
+
- Development installs now seed provider and multi-step demos automatically;
|
|
12
|
+
the installer and seed task print a copy-ready ERB trigger block.
|
|
13
|
+
- Clarified the permanent host/gem boundary: host apps own trigger timing,
|
|
14
|
+
Help menus, analytics persistence, reporting, and vendor integrations.
|
|
15
|
+
- Added immediate oEmbed-backed form previews, first-frame direct-video
|
|
16
|
+
previews, automatic CSP `frame-src` merging, language controls, key
|
|
17
|
+
normalization, and a simpler form layout.
|
|
18
|
+
- Aligned the public modal shell and mobile behavior with the shared visual
|
|
19
|
+
patterns used by the `testimonials` and `ideasbugs` gems.
|
|
20
|
+
- Added explicit Publish and Move to drafts dashboard actions, and linked the
|
|
21
|
+
seeded walkthrough through every supported video provider.
|
|
22
|
+
- Aligned the admin dashboard with the shared sibling-gem page, card, queue,
|
|
23
|
+
detail, action, and responsive patterns; post metadata now lives in Details.
|
|
24
|
+
- Added copy-ready launchers for a seeded draft post and an intentionally
|
|
25
|
+
missing key so developers can exercise both unresolved-trigger paths.
|
|
26
|
+
- Reduced configuration to host gating, layout, mount path, and optional storage;
|
|
27
|
+
locale resolution now follows current then default locale, production failures
|
|
28
|
+
report through `Rails.error`, and lifecycle payloads contain no gem-owned user,
|
|
29
|
+
tenant, or visitor identity.
|
|
30
|
+
- Renamed admin-facing records to tutorials, put Published before Draft, made
|
|
31
|
+
identity fields full-width, and replaced simultaneous URL/upload inputs with a
|
|
32
|
+
clear video-source choice.
|
|
33
|
+
- Simplified lifecycle notifications to tutorial, page, and source context;
|
|
34
|
+
removed navigation/progress metadata and inconsistent video-ended completion.
|
|
35
|
+
- Made the default locale the canonical admin tutorial list; new product tours now
|
|
36
|
+
start in that locale and translations are added and opened from each tutorial.
|
|
37
|
+
- Removed generic tutorial duplication now that translations provide the only
|
|
38
|
+
intentional content-copying workflow.
|
|
39
|
+
|
|
40
|
+
[Unreleased]: https://github.com/yshmarov/product_tours/compare/v0.1.0...HEAD
|
|
41
|
+
[0.1.0]: https://github.com/yshmarov/product_tours/releases/tag/v0.1.0
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Copyright (c) 2026 Yaroslav Shmarov
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
in the Software without restriction, including without limitation the rights
|
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# product_tours
|
|
2
|
+
|
|
3
|
+
[](https://github.com/yshmarov/product_tours/actions/workflows/ci.yml)
|
|
4
|
+
[](MIT-LICENSE)
|
|
5
|
+
|
|
6
|
+
`product_tours` is a self-hosted Rails engine for in-app product tutorials and
|
|
7
|
+
lightweight multi-step walkthroughs. Your app owns the trigger UI; the gem owns
|
|
8
|
+
the tutorial editor, translations, safe video embeds, and modal navigation.
|
|
9
|
+
|
|
10
|
+
Requires Ruby 3.2+ and Rails 7.1+.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
gem "product_tours"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
bin/rails generate product_tours:install
|
|
20
|
+
bin/rails db:migrate
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The migration automatically creates ready-to-use demo tutorials in development.
|
|
24
|
+
Production databases are never populated with demo content.
|
|
25
|
+
|
|
26
|
+
Add the widget before `</body>` in your application layout:
|
|
27
|
+
|
|
28
|
+
```erb
|
|
29
|
+
<%= product_tours_tag %>
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Use any host-owned button, link, or icon as the trigger:
|
|
33
|
+
|
|
34
|
+
```erb
|
|
35
|
+
<button class="btn btn-primary" data-product-tour="billing_setup">
|
|
36
|
+
Watch setup guide
|
|
37
|
+
</button>
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Create and publish `billing_setup` in the dashboard at `/product_tours`. Keys
|
|
41
|
+
are unique per locale. Draft, missing, invalid, or disabled keys open nothing
|
|
42
|
+
and raise in development/test; production reports them through `Rails.error`
|
|
43
|
+
and `product_tours.unresolved_trigger` instrumentation.
|
|
44
|
+
|
|
45
|
+
### Host-controlled invocation
|
|
46
|
+
|
|
47
|
+
The host app decides where and when a guide opens. It can use a visible button,
|
|
48
|
+
compose several buttons into its own Help menu, or activate a hidden trigger
|
|
49
|
+
after page load. For example, Rails flash state and a host Stimulus controller
|
|
50
|
+
can open a guide after a redirect:
|
|
51
|
+
|
|
52
|
+
```erb
|
|
53
|
+
<% if flash[:product_tour].present? %>
|
|
54
|
+
<button hidden
|
|
55
|
+
data-controller="product-tour-autoplay"
|
|
56
|
+
data-product-tour="<%= flash[:product_tour] %>"></button>
|
|
57
|
+
<% end %>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
// product_tour_autoplay_controller.js in the host app
|
|
62
|
+
connect() {
|
|
63
|
+
this.element.click()
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The gem deliberately has no page-rule engine, automatic display scheduler,
|
|
68
|
+
resource center, or guides launcher. Those are host navigation and presentation
|
|
69
|
+
choices built from the same `data-product-tour` attribute.
|
|
70
|
+
|
|
71
|
+
## Tutorials
|
|
72
|
+
|
|
73
|
+
A tutorial is stored internally as a `ProductTours::Post`. It has a title, key,
|
|
74
|
+
locale, `draft`/`published` status,
|
|
75
|
+
optional video, optional rich description, and one primary action. Video URLs
|
|
76
|
+
support YouTube, Vimeo, Loom, Tella, Voomly, and direct MP4/WebM files.
|
|
77
|
+
|
|
78
|
+
The dashboard previews a pasted video URL immediately. YouTube, Vimeo, and Loom
|
|
79
|
+
metadata is fetched through oEmbed; the other supported providers still get a
|
|
80
|
+
safe resolved preview. Direct videos seek to an early frame so they do not look
|
|
81
|
+
like an empty player before playback.
|
|
82
|
+
|
|
83
|
+
The admin sidebar treats `I18n.default_locale` as the canonical tutorial list.
|
|
84
|
+
The New product tour button always creates that default-language record. Open a
|
|
85
|
+
tutorial to see its existing languages or add another available locale; each
|
|
86
|
+
translation remains an ordinary draft/published `Post` with the same key.
|
|
87
|
+
|
|
88
|
+
When the host has a Rails Content Security Policy, the engine preserves its
|
|
89
|
+
existing `frame-src` entries and automatically adds the supported embed origins.
|
|
90
|
+
For a direct video hosted on a custom origin, the host remains responsible for
|
|
91
|
+
allowing that origin in `media-src` (or can allow HTTPS media generally).
|
|
92
|
+
|
|
93
|
+
### Link tutorials into a walkthrough
|
|
94
|
+
|
|
95
|
+
In the tutorial editor, choose what the primary button does: finish and close,
|
|
96
|
+
open a page, or continue to another tutorial. Continuing opens the selected
|
|
97
|
+
tutorial in the
|
|
98
|
+
same modal and automatically gives visitors a Back button.
|
|
99
|
+
|
|
100
|
+
Tutorials remain independently invokable—there is no separate course, step, or
|
|
101
|
+
sequence model. The Back history exists only for the open modal, so a tutorial opened
|
|
102
|
+
directly never shows a misleading Back button.
|
|
103
|
+
|
|
104
|
+
Video uploads require Active Storage:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
bin/rails active_storage:install
|
|
108
|
+
bin/rails db:migrate
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Rich descriptions require Action Text:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
bin/rails action_text:install
|
|
115
|
+
bin/rails db:migrate
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The post form hides either feature when its Rails framework is not installed.
|
|
119
|
+
|
|
120
|
+
## Demo tutorials
|
|
121
|
+
|
|
122
|
+
The installer seeds the app's default locale in development. Copy the block it
|
|
123
|
+
prints into any ERB view to open every demo entry point immediately:
|
|
124
|
+
|
|
125
|
+
```erb
|
|
126
|
+
<div class="product-tours-demo">
|
|
127
|
+
<button type="button" data-product-tour="demo_walkthrough_start">Try the multi-step walkthrough</button>
|
|
128
|
+
<button type="button" data-product-tour="demo_youtube">Open the YouTube tutorial</button>
|
|
129
|
+
<button type="button" data-product-tour="demo_vimeo">Open the Vimeo tutorial</button>
|
|
130
|
+
<button type="button" data-product-tour="demo_loom">Open the Loom tutorial</button>
|
|
131
|
+
<button type="button" data-product-tour="demo_tella">Open the Tella tutorial</button>
|
|
132
|
+
<button type="button" data-product-tour="demo_voomly">Open the Voomly tutorial</button>
|
|
133
|
+
<button type="button" data-product-tour="demo_direct_video">Open the direct video tutorial</button>
|
|
134
|
+
<button type="button" data-product-tour="demo_getting_started">Open the getting started guide</button>
|
|
135
|
+
<button type="button" data-product-tour="demo_draft">Try an unpublished tutorial</button>
|
|
136
|
+
<button type="button" data-product-tour="demo_missing_post">Try a missing tutorial</button>
|
|
137
|
+
</div>
|
|
138
|
+
<%= product_tours_tag %>
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`demo_walkthrough_start` introduces the collection, then continues through the
|
|
142
|
+
YouTube, Vimeo, Loom, Tella, Voomly, and direct-video tutorials before its final
|
|
143
|
+
step. Every transition demonstrates the automatic Back button. The individual
|
|
144
|
+
provider buttons remain available so each video can also be opened directly.
|
|
145
|
+
The final two buttons deliberately exercise unresolved triggers: `demo_draft`
|
|
146
|
+
exists but remains unpublished, while `demo_missing_post` is never seeded.
|
|
147
|
+
|
|
148
|
+
Refresh the full idempotent demo set in English, French, and Bulgarian at any
|
|
149
|
+
time:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
bin/rails product_tours:seed_demo
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Along with the provider examples, it creates `demo_walkthrough_start`,
|
|
156
|
+
`demo_walkthrough_features`, `demo_walkthrough_finish`, and the unpublished
|
|
157
|
+
`demo_draft` in the default demo locales (`en`, `fr`, and `bg`). The missing-key
|
|
158
|
+
button intentionally has no matching record. Running the task again refreshes
|
|
159
|
+
the seeded records instead of duplicating them and prints the copy-ready block
|
|
160
|
+
again. To seed only one locale from application code, call
|
|
161
|
+
`ProductTours::Seeds.load!(locale: :fr)`.
|
|
162
|
+
|
|
163
|
+
## Configuration
|
|
164
|
+
|
|
165
|
+
The installer creates `config/initializers/product_tours.rb`. Important hooks:
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
ProductTours.configure do |config|
|
|
169
|
+
config.authorize_admin = ->(request) { request.env["warden"]&.user&.admin? }
|
|
170
|
+
end
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Tutorial lookup automatically uses the page's current `I18n.locale`, then falls
|
|
174
|
+
back to `I18n.default_locale` when that key has no current-locale record. An
|
|
175
|
+
existing draft translation is not bypassed by the fallback.
|
|
176
|
+
|
|
177
|
+
## Lifecycle Notifications
|
|
178
|
+
|
|
179
|
+
The widget emits `product_tours.viewed`, `product_tours.dismissed`, and
|
|
180
|
+
`product_tours.completed` through `ActiveSupport::Notifications`. The gem does
|
|
181
|
+
not persist analytics and does not depend on Ahoy. `completed` means the visitor
|
|
182
|
+
used the tutorial's primary action; video playback does not imply completion.
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
ActiveSupport::Notifications.subscribe(/^product_tours\./) do |name, _start, _finish, _id, payload|
|
|
186
|
+
Ahoy.track(name.delete_prefix("product_tours."), payload.slice(:key, :locale, :source))
|
|
187
|
+
end
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
The host may store these events with Ahoy and attach its own user or account
|
|
191
|
+
context in the subscriber. Persistence, dashboards, identity, and vendor-specific
|
|
192
|
+
integrations remain outside this gem.
|
|
193
|
+
|
|
194
|
+
## Scope
|
|
195
|
+
|
|
196
|
+
`product_tours` owns tutorial persistence and editing, safe media rendering, the
|
|
197
|
+
modal experience, linked-tutorial navigation, and lifecycle notifications. The host
|
|
198
|
+
app owns trigger timing and placement, Help/resource menus, analytics storage,
|
|
199
|
+
reporting, and downstream integrations.
|
|
200
|
+
|
|
201
|
+
## Development
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
bundle install
|
|
205
|
+
bundle exec rake test
|
|
206
|
+
bundle exec rubocop
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Rails 7.1+ and Ruby 3.2+ are supported.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module ProductTours
|
|
6
|
+
class ApplicationController < ActionController::Base
|
|
7
|
+
protect_from_forgery with: :exception
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def product_tours_admin_layout
|
|
12
|
+
ProductTours.config.admin_layout
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def require_admin
|
|
16
|
+
return if ProductTours.admin?(request)
|
|
17
|
+
|
|
18
|
+
render plain: 'Forbidden. Set ProductTours.config.authorize_admin to grant access.', status: :forbidden
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def current_product_tours_locale
|
|
22
|
+
requested_locale = params[:locale].to_s
|
|
23
|
+
return requested_locale if I18n.available_locales.map(&:to_s).include?(requested_locale)
|
|
24
|
+
|
|
25
|
+
ProductTours.locale(request)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def find_post_by_locale(scope, key)
|
|
29
|
+
locales = [current_product_tours_locale, I18n.default_locale.to_s].uniq
|
|
30
|
+
posts = scope.where(locale: locales, key: key).index_by(&:locale)
|
|
31
|
+
locales.filter_map { |locale| posts[locale] }.first
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def clean_page_url(value)
|
|
35
|
+
uri = URI.parse(value.to_s)
|
|
36
|
+
return unless uri.is_a?(URI::HTTP) && uri.host.present?
|
|
37
|
+
|
|
38
|
+
uri.query = nil
|
|
39
|
+
uri.fragment = nil
|
|
40
|
+
uri.to_s
|
|
41
|
+
rescue URI::InvalidURIError
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def unresolved_payload(key, reason)
|
|
46
|
+
{
|
|
47
|
+
key: key.to_s,
|
|
48
|
+
locale: current_product_tours_locale,
|
|
49
|
+
reason: reason.to_s,
|
|
50
|
+
page_url: clean_page_url(params[:page_url])
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def handle_unresolved_trigger(key, reason)
|
|
55
|
+
payload = unresolved_payload(key, reason)
|
|
56
|
+
error = ProductTours::UnresolvedTriggerError.new(payload)
|
|
57
|
+
raise error if Rails.env.development? || Rails.env.test?
|
|
58
|
+
|
|
59
|
+
if defined?(Rails.error) && Rails.error.respond_to?(:report)
|
|
60
|
+
Rails.error.report(error, handled: true, context: payload)
|
|
61
|
+
else
|
|
62
|
+
Rails.logger.error("product_tours: #{error.message} #{payload.inspect}")
|
|
63
|
+
end
|
|
64
|
+
ActiveSupport::Notifications.instrument('product_tours.unresolved_trigger', payload)
|
|
65
|
+
render json: { error: 'unresolved_trigger', reason: reason.to_s }, status: :not_found
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ProductTours
|
|
4
|
+
class MediaController < ApplicationController
|
|
5
|
+
def show
|
|
6
|
+
post = Post.find(params[:id])
|
|
7
|
+
allowed = ProductTours.admin?(request) || (ProductTours.enabled?(request) && post.published?)
|
|
8
|
+
return head :forbidden unless allowed
|
|
9
|
+
return head :not_found unless post.uploaded_video?
|
|
10
|
+
|
|
11
|
+
redirect_to main_app.rails_blob_path(post.video, disposition: 'inline')
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ProductTours
|
|
4
|
+
class PostsController < ApplicationController
|
|
5
|
+
PER_PAGE = 50
|
|
6
|
+
|
|
7
|
+
layout :product_tours_admin_layout
|
|
8
|
+
before_action :require_admin
|
|
9
|
+
before_action :set_post,
|
|
10
|
+
only: %i[show edit update destroy refresh_video_metadata add_translation publish unpublish]
|
|
11
|
+
before_action :load_linkable_posts, only: %i[new create edit update]
|
|
12
|
+
before_action :load_translations, only: :show
|
|
13
|
+
|
|
14
|
+
def index
|
|
15
|
+
@status = Post::STATUSES.include?(params[:status]) ? params[:status] : 'published'
|
|
16
|
+
@query = params[:q].to_s.strip.presence
|
|
17
|
+
@counts = Post.where(locale: default_locale).group(:status).count
|
|
18
|
+
|
|
19
|
+
key_scope = Post.where(status: @status, locale: default_locale)
|
|
20
|
+
@keys = key_scope.distinct.order(:key).pluck(:key)
|
|
21
|
+
|
|
22
|
+
scope = posts_with_video.newest_first.where(status: @status, locale: default_locale)
|
|
23
|
+
scope = scope.where('LOWER(key) LIKE ?', "%#{Post.sanitize_sql_like(@query.downcase)}%") if @query
|
|
24
|
+
@page = [params[:page].to_i, 1].max
|
|
25
|
+
@posts = scope.offset((@page - 1) * PER_PAGE).limit(PER_PAGE + 1).to_a
|
|
26
|
+
@more = @posts.size > PER_PAGE
|
|
27
|
+
@posts = @posts.first(PER_PAGE)
|
|
28
|
+
|
|
29
|
+
@selected_post = posts_with_video.find_by(id: params[:post_id]) if params[:post_id].present?
|
|
30
|
+
load_translations if @selected_post
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def show; end
|
|
34
|
+
|
|
35
|
+
def new
|
|
36
|
+
@post = Post.new(locale: default_locale)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def create
|
|
40
|
+
@post = Post.new(post_attributes.merge(locale: default_locale))
|
|
41
|
+
if @post.save
|
|
42
|
+
redirect_to post_path(@post), notice: t('product_tours.dashboard.created', default: 'Tutorial created.')
|
|
43
|
+
else
|
|
44
|
+
render :new, status: :unprocessable_entity
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def edit; end
|
|
49
|
+
|
|
50
|
+
def update
|
|
51
|
+
if @post.update(post_attributes)
|
|
52
|
+
remove_uploaded_video if remove_uploaded_video?
|
|
53
|
+
redirect_to post_path(@post), notice: t('product_tours.dashboard.updated', default: 'Tutorial updated.')
|
|
54
|
+
else
|
|
55
|
+
render :edit, status: :unprocessable_entity
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def destroy
|
|
60
|
+
if default_locale_post?(@post) && translations_for(@post).where.not(id: @post.id).exists?
|
|
61
|
+
redirect_to post_path(@post),
|
|
62
|
+
alert: t('product_tours.dashboard.delete_translations_first',
|
|
63
|
+
default: 'Delete this tutorial\'s translations before deleting the ' \
|
|
64
|
+
'default-language tutorial.')
|
|
65
|
+
return
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
@post.destroy!
|
|
69
|
+
redirect_to posts_path,
|
|
70
|
+
notice: t('product_tours.dashboard.deleted', default: 'Tutorial deleted.'), status: :see_other
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def add_translation
|
|
74
|
+
locale = params[:locale].to_s
|
|
75
|
+
unless available_locales.include?(locale) && locale != @post.locale
|
|
76
|
+
redirect_to post_path(@post),
|
|
77
|
+
alert: t('product_tours.dashboard.translation_locale_invalid',
|
|
78
|
+
default: 'Choose another available language.')
|
|
79
|
+
return
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
existing = Post.find_by(locale: locale, key: @post.key)
|
|
83
|
+
if existing
|
|
84
|
+
redirect_to post_path(existing),
|
|
85
|
+
notice: t('product_tours.dashboard.translation_exists',
|
|
86
|
+
default: 'That translation already exists.')
|
|
87
|
+
return
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
source = translations_for(@post).find_by(locale: default_locale) || @post
|
|
91
|
+
translation = source.dup
|
|
92
|
+
translation.locale = locale
|
|
93
|
+
translation.status = 'draft'
|
|
94
|
+
if translation.action_post_key.present? &&
|
|
95
|
+
!Post.exists?(locale: locale, key: translation.action_post_key)
|
|
96
|
+
translation.action_post_key = nil
|
|
97
|
+
end
|
|
98
|
+
translation.save!
|
|
99
|
+
if Post.description_supported? && source.description.present?
|
|
100
|
+
translation.update!(description: source.description.body)
|
|
101
|
+
end
|
|
102
|
+
translation.video.attach(source.video.blob) if source.uploaded_video?
|
|
103
|
+
|
|
104
|
+
redirect_to edit_post_path(translation),
|
|
105
|
+
notice: t('product_tours.dashboard.translation_added',
|
|
106
|
+
default: 'Translation added as a draft. Translate its content before publishing.')
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def publish
|
|
110
|
+
@post.update!(status: 'published')
|
|
111
|
+
redirect_to post_path(@post), notice: t('product_tours.dashboard.published', default: 'Tutorial published.')
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def unpublish
|
|
115
|
+
@post.update!(status: 'draft')
|
|
116
|
+
redirect_to post_path(@post),
|
|
117
|
+
notice: t('product_tours.dashboard.unpublished', default: 'Tutorial moved to drafts.')
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def video_preview
|
|
121
|
+
resolved = VideoResolver.resolve(params[:url])
|
|
122
|
+
unless resolved
|
|
123
|
+
render json: { error: t('product_tours.dashboard.video_invalid', default: 'Enter a supported video URL.') },
|
|
124
|
+
status: :unprocessable_entity
|
|
125
|
+
return
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
metadata = VideoMetadata.fetch(params[:url])
|
|
129
|
+
render json: {
|
|
130
|
+
kind: resolved.kind,
|
|
131
|
+
provider: resolved.provider,
|
|
132
|
+
url: resolved.url,
|
|
133
|
+
title: metadata['provider_title'],
|
|
134
|
+
thumbnail_url: metadata['thumbnail_url']
|
|
135
|
+
}.compact
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def refresh_video_metadata
|
|
139
|
+
metadata = VideoMetadata.fetch(@post.video_url)
|
|
140
|
+
if metadata.present?
|
|
141
|
+
@post.update!(video_metadata: @post.video_metadata.to_h.merge(metadata))
|
|
142
|
+
redirect_to edit_post_path(@post),
|
|
143
|
+
notice: t('product_tours.dashboard.metadata_updated', default: 'Video data refreshed.')
|
|
144
|
+
else
|
|
145
|
+
redirect_to edit_post_path(@post),
|
|
146
|
+
alert: t('product_tours.dashboard.metadata_failed',
|
|
147
|
+
default: 'Video data could not be fetched. The URL was not changed.')
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
def posts_with_video
|
|
154
|
+
Post.video_upload_supported? ? Post.with_attached_video : Post.all
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def set_post
|
|
158
|
+
@post = Post.find(params[:id])
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def post_params
|
|
162
|
+
permitted = %i[key status title video_url action_label action_url action_post_key]
|
|
163
|
+
permitted << :description if Post.description_supported?
|
|
164
|
+
permitted << :video if Post.video_upload_supported?
|
|
165
|
+
params.require(:post).permit(*permitted)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def post_attributes
|
|
169
|
+
attributes = post_params
|
|
170
|
+
attributes.delete(:key) if identity_locked?
|
|
171
|
+
case params[:video_source]
|
|
172
|
+
when 'url'
|
|
173
|
+
attributes.delete(:video)
|
|
174
|
+
when 'upload'
|
|
175
|
+
attributes[:video_url] = nil
|
|
176
|
+
end
|
|
177
|
+
case params[:action_target]
|
|
178
|
+
when 'close'
|
|
179
|
+
attributes[:action_url] = nil
|
|
180
|
+
attributes[:action_post_key] = nil
|
|
181
|
+
when 'url'
|
|
182
|
+
attributes[:action_post_key] = nil
|
|
183
|
+
when 'post'
|
|
184
|
+
attributes[:action_url] = nil
|
|
185
|
+
end
|
|
186
|
+
attributes
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def load_linkable_posts
|
|
190
|
+
@linkable_posts = Post.where.not(id: @post&.id).order(:locale, :title, :key)
|
|
191
|
+
.select(:id, :key, :locale, :status, :title)
|
|
192
|
+
@identity_locked = identity_locked?
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def load_translations
|
|
196
|
+
post = @selected_post || @post
|
|
197
|
+
@translations = translations_for(post).order(:locale).to_a
|
|
198
|
+
@missing_translation_locales = available_locales - @translations.map(&:locale)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def translations_for(post)
|
|
202
|
+
Post.where(key: post.key)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def available_locales
|
|
206
|
+
(I18n.available_locales.map(&:to_s) + [default_locale]).uniq.sort
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def default_locale
|
|
210
|
+
I18n.default_locale.to_s
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def default_locale_post?(post)
|
|
214
|
+
post.locale == default_locale
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def identity_locked?
|
|
218
|
+
@post&.persisted? &&
|
|
219
|
+
(!default_locale_post?(@post) || translations_for(@post).where.not(id: @post.id).exists?)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def remove_uploaded_video?
|
|
223
|
+
params[:video_source] == 'url' && @post.uploaded_video?
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def remove_uploaded_video
|
|
227
|
+
@post.video.purge if @post.uploaded_video?
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ProductTours
|
|
4
|
+
class ToursController < ApplicationController
|
|
5
|
+
SIGNALS = %w[viewed dismissed completed].freeze
|
|
6
|
+
|
|
7
|
+
def resolve
|
|
8
|
+
key = params[:key].to_s
|
|
9
|
+
return handle_unresolved_trigger(key, :disabled) unless ProductTours.enabled?(request)
|
|
10
|
+
return handle_unresolved_trigger(key, :invalid_key) unless key.match?(Post::KEY_FORMAT)
|
|
11
|
+
|
|
12
|
+
post = find_post_by_locale(Post.all, key)
|
|
13
|
+
return handle_unresolved_trigger(key, :missing) unless post
|
|
14
|
+
return handle_unresolved_trigger(key, :unpublished) unless post.published?
|
|
15
|
+
|
|
16
|
+
render json: post_payload(post)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def signal
|
|
20
|
+
return head :forbidden unless ProductTours.enabled?(request)
|
|
21
|
+
|
|
22
|
+
action = params[:event_action].to_s
|
|
23
|
+
return head :unprocessable_entity unless SIGNALS.include?(action)
|
|
24
|
+
|
|
25
|
+
post = find_post_by_locale(Post.published, params[:key].to_s)
|
|
26
|
+
return head :not_found unless post
|
|
27
|
+
|
|
28
|
+
payload = {
|
|
29
|
+
post_id: post.id,
|
|
30
|
+
key: post.key,
|
|
31
|
+
locale: post.locale,
|
|
32
|
+
page_url: clean_page_url(params[:page_url]),
|
|
33
|
+
source: params[:source].to_s.presence
|
|
34
|
+
}
|
|
35
|
+
ActiveSupport::Notifications.instrument("product_tours.#{action}", payload)
|
|
36
|
+
head :no_content
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def post_payload(post)
|
|
42
|
+
video = post.resolved_video
|
|
43
|
+
video[:url] = media_path(post) if video&.dig(:kind) == 'upload'
|
|
44
|
+
{
|
|
45
|
+
key: post.key,
|
|
46
|
+
locale: post.locale,
|
|
47
|
+
title: post.title,
|
|
48
|
+
descriptionHtml: description_html(post),
|
|
49
|
+
video: video,
|
|
50
|
+
action: {
|
|
51
|
+
label: post.action_label.presence || default_action_label(post),
|
|
52
|
+
url: post.action_url.presence,
|
|
53
|
+
postKey: post.action_post_key.presence
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def default_action_label(post)
|
|
59
|
+
key = post.action_post_key.present? ? :next : :done
|
|
60
|
+
I18n.t(key, scope: :product_tours, default: key.to_s.humanize)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def description_html(post)
|
|
64
|
+
return unless Post.description_supported? && post.description.present?
|
|
65
|
+
|
|
66
|
+
post.description.body.to_s
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|